@vxil/cli 0.13.0 → 0.13.2

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/vxil.js CHANGED
@@ -2891,12 +2891,12 @@ var NotificationsConfigSchema = Type.Object({
2891
2891
  // need no email account, so the mock path is zero-config. A cross-field
2892
2892
  // check in validateFeatureConfig requires it only when provider === 'resend'.
2893
2893
  resendApiKeyRef: Type.Optional(Type.String()),
2894
- // Optional per-tenant Resend/Svix ENDPOINT secret ref (public.tenant_secrets,
2895
- // envelope-encrypted under KEK_NOTIFICATIONS — same store as resendApiKeyRef).
2894
+ // Optional per-tenant Resend/Svix ENDPOINT secret ref (a pointer into the
2895
+ // tenant's encrypted secret store — same store as resendApiKeyRef).
2896
2896
  // When set, inbound Resend webhooks are verified with THIS tenant's secret
2897
2897
  // instead of the platform-wide PROVIDER_WEBHOOK_SECRET, binding the signature
2898
2898
  // to the tenant so a signed event for tenant A can never validate at tenant
2899
- // B's webhook URL (audit H6 — mirrors payments revenuecat.webhookSecretRef).
2899
+ // B's webhook URL (mirrors payments revenuecat.webhookSecretRef).
2900
2900
  webhookSecretRef: Type.Optional(Type.String()),
2901
2901
  // 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP;
2902
2902
  // 'ses' (2026-09-19) is the second real provider — Amazon SES v2, BYO IAM
@@ -2908,7 +2908,7 @@ var NotificationsConfigSchema = Type.Object({
2908
2908
  // cap rule — funded by folding `rateLimit` into an Optional bag below);
2909
2909
  // NO `default: {}` so an absent bag stays absent (the auth `security`
2910
2910
  // precedent). Both refs are `secret:<name>` POINTERS into
2911
- // public.tenant_secrets (feature='notifications', KEK_NOTIFICATIONS) —
2911
+ // the tenant's encrypted secret store (feature='notifications') —
2912
2912
  // exactly how resendApiKeyRef resolves; the region is plain config (not a
2913
2913
  // secret) and is pattern-pinned to the AWS region grammar so a typo fails
2914
2914
  // at `vxil push` instead of as a DNS error on the first send. Required
@@ -2930,7 +2930,7 @@ var NotificationsConfigSchema = Type.Object({
2930
2930
  // nested objects carry `default: {}` so Value.Default can materialize them
2931
2931
  // and then recurse into the leaf defaults.
2932
2932
  // `retry` became an OPTIONAL bag (2 leaves → 1, countLeaves counts an
2933
- // Optional object as ONE) to fund `broadcast` below (M21/#4, 2026-07-18).
2933
+ // Optional object as ONE) to fund `broadcast` below (2026-07-18).
2934
2934
  // It KEEPS `default: {}`, which Value.Default still materializes — so every
2935
2935
  // persisted manifest carries retry.{maxAttempts,backoff} exactly as before
2936
2936
  // (zero behavioral delta); only the TS type is now optional (workers read
@@ -2948,7 +2948,7 @@ var NotificationsConfigSchema = Type.Object({
2948
2948
  { softBounceThreshold: Type.Integer({ default: 3 }) },
2949
2949
  { default: {} }
2950
2950
  ),
2951
- // `rateLimit` became an OPTIONAL bag (2 leaves → 1, the M21 `retry` trick)
2951
+ // `rateLimit` became an OPTIONAL bag (2 leaves → 1, the `retry` trick)
2952
2952
  // on 2026-09-19 to fund the `ses` credential bag above. It KEEPS
2953
2953
  // `default: {}`, so Value.Default still materializes
2954
2954
  // rateLimit.{perDay,perTenantSec} into every persisted manifest exactly as
@@ -2961,8 +2961,8 @@ var NotificationsConfigSchema = Type.Object({
2961
2961
  },
2962
2962
  { default: {} }
2963
2963
  )),
2964
- // `templates` became an OPTIONAL bag (1 leaf, the same M21 trick `retry`
2965
- // uses) to fund the D4 per-locale `overrides` map WITHOUT moving the count:
2964
+ // `templates` became an OPTIONAL bag (1 leaf, the same trick `retry`
2965
+ // uses) to fund the per-locale `overrides` map WITHOUT moving the count:
2966
2966
  // countLeaves scores an Optional object as ONE. `default: {}` is KEPT, so
2967
2967
  // Value.Default still materializes `templates.allowOverride` into every
2968
2968
  // persisted manifest exactly as before — zero behavioral delta; only the TS
@@ -2970,7 +2970,7 @@ var NotificationsConfigSchema = Type.Object({
2970
2970
  templates: Type.Optional(Type.Object(
2971
2971
  {
2972
2972
  allowOverride: Type.Boolean({ default: false }),
2973
- // D4 (2026-09-10) — TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
2973
+ // (2026-09-10) TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
2974
2974
  // template ids, as config DATA (a Record = 1 leaf, catalog size never
2975
2975
  // moves the count). Shape: { [templateId]: { [locale]: { subject?,
2976
2976
  // html?, text? } } }. Same escaped `{{placeholder}}` grammar as the
@@ -2999,10 +2999,10 @@ var NotificationsConfigSchema = Type.Object({
2999
2999
  )),
3000
3000
  /** in-app inbox channel (send with channel: 'inbox' | 'both') */
3001
3001
  inboxEnabled: Type.Boolean({ default: false }),
3002
- /** §11b.5 broadcast campaigns channel. Optional bag (= 1 leaf): absent means
3002
+ /** Broadcast campaigns channel. Optional bag (= 1 leaf): absent means
3003
3003
  * disabled; per-campaign quiet_hours / freq_cap overrides live on the
3004
3004
  * notifications.campaigns ROW (tenant data), not here. Folded into the
3005
- * canonical schema 2026-07-18 (M21/#4 — Value.Clean previously STRIPPED the
3005
+ * canonical schema 2026-07-18 (Value.Clean previously STRIPPED the
3006
3006
  * worker-local extension, so campaigns 403'd via the real config path). */
3007
3007
  broadcast: Type.Optional(Type.Object({
3008
3008
  enabled: Type.Boolean({ default: false }),
@@ -3070,7 +3070,7 @@ var JobsConfigSchema = Type.Object({
3070
3070
  { maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) },
3071
3071
  { default: {} }
3072
3072
  ),
3073
- // 2.F6 generation lifecycle knobs (jobs.md §11). The defaults and bounds are
3073
+ // Generation lifecycle knobs (guide ch. 6, jobs). The defaults and bounds are
3074
3074
  // declared ONCE, as GENERATION_DEFAULTS / GENERATION_BOUNDS below this schema
3075
3075
  // (2026-09-25): jobs-v1's resolveGenerationConfig imports them and clamps a
3076
3076
  // pre-fold manifest to the same numbers — a hand-mirrored copy used to live
@@ -3085,14 +3085,14 @@ var JobsConfigSchema = Type.Object({
3085
3085
  maxTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.maxTimeoutMs, minimum: GENERATION_BOUNDS.maxTimeoutMs.min, maximum: GENERATION_BOUNDS.maxTimeoutMs.max }),
3086
3086
  /** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
3087
3087
  pollMaxAttempts: Type.Integer({ default: GENERATION_DEFAULTS.pollMaxAttempts, minimum: GENERATION_BOUNDS.pollMaxAttempts.min, maximum: GENERATION_BOUNDS.pollMaxAttempts.max }),
3088
- /** MANDATORY per-hold cap on a generation `reserve_credits.amount` (jobs.md
3089
- * §11.8). Every requested amount is CLAMPED to this (never rejected) — a
3088
+ /** MANDATORY per-hold cap on a generation `reserve_credits.amount`
3089
+ * (guide ch. 6, jobs). Every requested amount is CLAMPED to this (never rejected) — a
3090
3090
  * conservative default so an untrusted deployed function that carries a
3091
3091
  * reserve block can never hold more than a bounded amount per run without
3092
3092
  * any tenant action. */
3093
3093
  maxReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxReserveCredits, minimum: GENERATION_BOUNDS.maxReserveCredits.min, maximum: GENERATION_BOUNDS.maxReserveCredits.max }),
3094
3094
  /** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
3095
- * reserve holds across all in-flight generation runs (jobs.md §11.8): a
3095
+ * reserve holds across all in-flight generation runs (guide ch. 6, jobs): a
3096
3096
  * reserve whose amount would push the tenant's outstanding-holds total over
3097
3097
  * this is rejected 429, so a runaway function cannot hold every user at
3098
3098
  * once. Defaulted so no tenant action is required to be safe. */
@@ -3105,11 +3105,11 @@ var OAuthRefsSchema = Type.Object({
3105
3105
  // clientId/secret refs are OPTIONAL at the schema layer: a tenant may stage a
3106
3106
  // partial block, and the worker enforces presence at use (→ 501 if missing),
3107
3107
  // matching the worker's OAuthProviderRefs shape (core.ts). They stay POINTERS
3108
- // into tenant_secrets — never raw secrets (features/auth.md §6.5).
3108
+ // into the tenant's secret store — never raw secrets (guide ch. 6, auth).
3109
3109
  clientIdRef: Type.Optional(Type.String()),
3110
3110
  clientSecretRef: Type.Optional(Type.String()),
3111
3111
  // extra native-aud allow-list entries (iOS/web client ids that differ from the
3112
- // primary clientIdRef) — also tenant_secrets refs (features/auth.md §6.4).
3112
+ // primary clientIdRef) — also secret-store refs (guide ch. 6, auth).
3113
3113
  audRefs: Type.Optional(Type.Array(Type.String(), { maxItems: 16 }))
3114
3114
  });
3115
3115
  var AppleRefsSchema = Type.Object({
@@ -3118,9 +3118,9 @@ var AppleRefsSchema = Type.Object({
3118
3118
  teamId: Type.String(),
3119
3119
  keyId: Type.String(),
3120
3120
  p8KeyRef: Type.String(),
3121
- // tenant_secrets ref → envelope-encrypted .p8 PEM
3121
+ // secret-store ref → the encrypted .p8 PEM
3122
3122
  // extra native-aud allow-list entries: genuine iOS ASAuthorization id_tokens
3123
- // carry the app BUNDLE ID as aud, not the Services ID (auth.md §6.4). Plain
3123
+ // carry the app BUNDLE ID as aud, not the Services ID (guide ch. 6, auth). Plain
3124
3124
  // config values — bundle ids are not secrets. The worker already honors them
3125
3125
  // (oauthCore.ts nativeAudAllowList); declaring them here is what stops
3126
3126
  // Value.Clean stripping the field out of PUT /v1/config/auth.
@@ -3153,20 +3153,20 @@ var OidcProviderSchema = Type.Object({
3153
3153
  { maxItems: 32 }
3154
3154
  )),
3155
3155
  // default true: an identity whose VERIFIED email matches an existing user is
3156
- // linked to it (the shipped §6.4 rule). false: link only by the stable
3156
+ // linked to it (the shipped social sign-in rule). false: link only by the stable
3157
3157
  // (issuer, sub) anchor; a matching email that is not yet linked → 409.
3158
3158
  autoLink: Type.Optional(Type.Boolean())
3159
3159
  });
3160
3160
  var REDIRECT_ORIGIN_PATTERN = "^(https://[^/?#\\s]+|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$";
3161
3161
  var AuthConfigSchema = Type.Object({
3162
3162
  enabled: Type.Boolean({ default: true }),
3163
- // D3 (auth wave 2026-09-10): the six method toggles collapsed into ONE
3163
+ // (2026-09-10) the six method toggles collapsed into ONE
3164
3164
  // Optional bag (6 leaves → 1, countLeaves counts an Optional object as ONE) —
3165
3165
  // the notifications `retry`/`broadcast` precedent. It KEEPS `default: {}`,
3166
3166
  // which Value.Default still materializes, so every persisted manifest carries
3167
3167
  // methods.{emailPassword,magicLink,google,github,apple,facebook} with the
3168
3168
  // SAME keys and defaults as before — byte-identical folds for existing
3169
- // tenants (index.test.ts "D3 fold bytes"). Only the TS type is now optional;
3169
+ // tenants (index.test.ts pins the fold bytes). Only the TS type is now optional;
3170
3170
  // the worker's gate() normalizes an absent bag to the defaults so every
3171
3171
  // reader (config.methods.<flag>) is unchanged.
3172
3172
  methods: Type.Optional(Type.Object(
@@ -3184,12 +3184,12 @@ var AuthConfigSchema = Type.Object({
3184
3184
  // rule) keyed by provider, so the four provider blocks (and any future one)
3185
3185
  // never inflate the flag count — the prior shape spent a leaf per top-level
3186
3186
  // google/github block. This bag REPLACES those two top-level blocks (−2, +1
3187
- // for the bag) and EXTENDS the accepted providers to apple + facebook (§6):
3187
+ // for the bag) and EXTENDS the accepted providers to apple + facebook:
3188
3188
  // • google/github/facebook → { clientIdRef, clientSecretRef, audRefs? }
3189
3189
  // • apple → { servicesId, teamId, keyId, p8KeyRef }
3190
- // All *Ref fields are tenant_secrets POINTERS, never raw secrets (§6.5). The
3190
+ // All *Ref fields are secret-store POINTERS, never raw secrets. The
3191
3191
  // worker reads config.providers?.{google,github,apple,facebook} (oauthCore.ts).
3192
- // The §6.4 runtime sign-in flow for apple/facebook is SHIPPED in oauthCore.ts
3192
+ // The runtime sign-in flow for apple/facebook is SHIPPED in oauthCore.ts
3193
3193
  // (id_token/access_token verification + ES256 Apple client_secret minting);
3194
3194
  // methods.{apple,facebook} above are the enable toggles it gates on.
3195
3195
  providers: Type.Optional(Type.Object({
@@ -3197,11 +3197,11 @@ var AuthConfigSchema = Type.Object({
3197
3197
  github: Type.Optional(OAuthRefsSchema),
3198
3198
  apple: Type.Optional(AppleRefsSchema),
3199
3199
  facebook: Type.Optional(OAuthRefsSchema),
3200
- // RB-2: the generic OIDC / SSO issuer (presence = enabled; see above)
3200
+ // the generic OIDC / SSO issuer (presence = enabled; see above)
3201
3201
  oidc: Type.Optional(OidcProviderSchema)
3202
3202
  })),
3203
3203
  // session/password are OPTIONAL bags (= ONE leaf each per the cap rule) since
3204
- // the OTP/anonymous/orgClaims wave — the `{ default: {} }` keeps the inner
3204
+ // the OTP/anonymous/orgClaims release — the `{ default: {} }` keeps the inner
3205
3205
  // defaults materializing on publish, so the worker still reads fully-populated
3206
3206
  // manifests; its `config.session?.ttlMinutes ?? 60` fallbacks cover sparse
3207
3207
  // hand-built manifests only.
@@ -3209,7 +3209,7 @@ var AuthConfigSchema = Type.Object({
3209
3209
  {
3210
3210
  ttlMinutes: Type.Integer({ default: 60, minimum: 5, maximum: 1440 }),
3211
3211
  refreshTtlDays: Type.Integer({ default: 30, minimum: 1, maximum: 365 }),
3212
- // A1 (auth wave 2026-09-10): concurrent-session cap per user with
3212
+ // (2026-09-10) concurrent-session cap per user with
3213
3213
  // TAKE-OVER — a new sign-in revokes the OLDEST sessions past the cap
3214
3214
  // (revoked_reason 'device_cap', edge cache written) and reports them as
3215
3215
  // `took_over: [session_id…]`. Optional WITHOUT a default so existing
@@ -3225,15 +3225,15 @@ var AuthConfigSchema = Type.Object({
3225
3225
  },
3226
3226
  { default: {} }
3227
3227
  )),
3228
- // Deviation (journaled): emailVerification.tokenTtlHours was DROPPED —
3228
+ // Note: emailVerification.tokenTtlHours was DROPPED —
3229
3229
  // consumer-less (grep-verified: only this schema + dist mentioned it; the
3230
3230
  // verify-email token flow it would bound was never built). Same precedent as
3231
- // the removed `redirects` block below. Its leaf funds the OTP wave.
3231
+ // the removed `redirects` block below. Its leaf funds the OTP sign-in bag.
3232
3232
  emailVerification: Type.Object(
3233
3233
  { required: Type.Boolean({ default: false }) },
3234
3234
  { default: {} }
3235
3235
  ),
3236
- // P1-16 (2026-09-18): magicLink is now an OPTIONAL bag (= ONE leaf however
3236
+ // (2026-09-18) magicLink is now an OPTIONAL bag (= ONE leaf however
3237
3237
  // many knobs it holds — the methods/session/password precedent) so the
3238
3238
  // request cooldown could land without spending a second leaf. It KEEPS
3239
3239
  // `default: {}`, so Value.Default still materializes
@@ -3242,7 +3242,7 @@ var AuthConfigSchema = Type.Object({
3242
3242
  magicLink: Type.Optional(Type.Object(
3243
3243
  {
3244
3244
  tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }),
3245
- // P1-16: the per-(tenant, identifier) magic-link REQUEST cooldown — the
3245
+ // the per-(tenant, identifier) magic-link REQUEST cooldown — the
3246
3246
  // `otp.resendCooldownSec` twin, same bounds so the two knobs read the
3247
3247
  // same. A second request for the same address inside the window is a 429
3248
3248
  // `magic_link_rate_limited` with a Retry-After header. Type.Optional with
@@ -3253,19 +3253,19 @@ var AuthConfigSchema = Type.Object({
3253
3253
  },
3254
3254
  { default: {} }
3255
3255
  )),
3256
- // P1-5 identity continuity (2026-09-18) — OPT-IN registry adoption. When
3256
+ // Identity continuity (2026-09-18) — OPT-IN registry adoption. When
3257
3257
  // true, a sign-in by a method that PROVES control of the address — magic
3258
3258
  // link, email OTP, or OAuth with a provider-verified address — reuses the id
3259
3259
  // of the one matching pre-registered end-user (POST /v1/users) that has no
3260
3260
  // account yet, instead of minting a fresh `user_<ulid>` and leaving the
3261
3261
  // tenant with two records for one person. Password SIGN-UP never adopts: it
3262
- // proves nothing about the address (auth.md §8.1 PROVEN_ADOPT_METHODS).
3262
+ // proves nothing about the address (guide ch. 6, auth).
3263
3263
  // OFF by default, and Type.Optional with NO default: adoption means whoever
3264
3264
  // proves control of a pre-registered address becomes that record — a change
3265
3265
  // of security semantics for a tenant that bulk-imports contacts, whose
3266
3266
  // addresses vxil never verified.
3267
3267
  registryAdopt: Type.Optional(Type.Boolean()),
3268
- // Email OTP sign-in (roadmap Tier-0) + the knobs step-up re-auth shares.
3268
+ // Email OTP sign-in + the knobs step-up re-auth shares.
3269
3269
  // OPTIONAL bag = 1 leaf; absent ⇒ disabled (the worker gates on
3270
3270
  // otp?.enabled === true).
3271
3271
  otp: Type.Optional(Type.Object({
@@ -3273,7 +3273,7 @@ var AuthConfigSchema = Type.Object({
3273
3273
  codeTtlMinutes: Type.Integer({ default: 10, minimum: 1, maximum: 60 }),
3274
3274
  maxAttempts: Type.Integer({ default: 5, minimum: 3, maximum: 10 }),
3275
3275
  resendCooldownSec: Type.Integer({ default: 60, minimum: 0, maximum: 600 }),
3276
- // F4-29 (auth wave 2026-09-10): test recipients — an OTP / step-up / email-
3276
+ // (2026-09-10) test recipients — an OTP / step-up / email-
3277
3277
  // claim request whose address matches an entry sends NO mail and returns
3278
3278
  // the code as `test_code` (audit auth.otp.test_issued). Entries: an exact
3279
3279
  // email, a `*@domain` glob, or a +E.164 number (accepted for the SMS
@@ -3285,11 +3285,11 @@ var AuthConfigSchema = Type.Object({
3285
3285
  { maxItems: 20 }
3286
3286
  ))
3287
3287
  })),
3288
- // Anonymous (guest) sign-in (roadmap Tier-0). OPTIONAL bag = 1 leaf.
3288
+ // Anonymous (guest) sign-in. OPTIONAL bag = 1 leaf.
3289
3289
  anonymous: Type.Optional(Type.Object({
3290
3290
  enabled: Type.Boolean({ default: false })
3291
3291
  })),
3292
- // Org claims embedded in session JWTs at mint/refresh (roadmap Tier-1C):
3292
+ // Org claims embedded in session JWTs at mint/refresh:
3293
3293
  // when enabled, auth-v1 fetches the user's active-org membership from orgs
3294
3294
  // over the EDGE and embeds { org_id, role, perms[] } as the `org` claim.
3295
3295
  // Fail-open: an orgs outage mints WITHOUT claims (sign-in never breaks).
@@ -3298,23 +3298,22 @@ var AuthConfigSchema = Type.Object({
3298
3298
  orgClaims: Type.Optional(Type.Object({
3299
3299
  enabled: Type.Boolean({ default: false })
3300
3300
  })),
3301
- // Deviation (journaled): the doc's §4 `redirects` block was DROPPED entirely —
3301
+ // Note: the earlier `redirects` block was DROPPED entirely —
3302
3302
  // it had zero consumers (grep-verified: no worker reads config.redirects) and
3303
3303
  // its leaf was spent on the methods.{apple,facebook} toggles the shipped
3304
- // §6.4 sign-in flow actually gates on. Re-adding it requires headroom or a
3304
+ // social sign-in flow actually gates on. Re-adding it requires headroom or a
3305
3305
  // collapse elsewhere.
3306
3306
  //
3307
- // Account-security controls (auth wave 2026-09-10, P0-3; features/auth.md
3308
- // §4b). ONE optional bag = 1 leaf; NO `default: {}` so an absent bag stays
3307
+ // Account-security controls (2026-09-10; guide ch. 6, auth). ONE optional bag = 1 leaf; NO `default: {}` so an absent bag stays
3309
3308
  // absent (existing manifests fold byte-identically) and every control is
3310
3309
  // opt-in:
3311
3310
  // • lockout — present ⇒ per-(tenant, identifier) failure lockout on password
3312
3311
  // sign-in + OTP verify (429 account_locked + Retry-After); a bounded
3313
- // counter row in auth.lockouts (migration 0088) survives restarts.
3312
+ // stored counter survives restarts.
3314
3313
  // • breachedPasswords — HIBP k-anonymity range check (first 5 SHA-1 hex
3315
3314
  // chars leave the worker, never the password) at sign-up / reset-confirm;
3316
3315
  // fail-OPEN on network error → 422 password_breached.
3317
- // • captchaSecretRef — a tenant_secrets ref (feature 'auth') holding the
3316
+ // • captchaSecretRef — a secret-store ref (feature 'auth') holding the
3318
3317
  // Turnstile secret; when set, sign-up / OTP request / magic-link request
3319
3318
  // require `captcha_token` (403 captcha_failed otherwise).
3320
3319
  // • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
@@ -3360,7 +3359,7 @@ var RateLimitsConfigSchema = Type.Object({
3360
3359
  });
3361
3360
  var FilesConfigSchema = Type.Object({
3362
3361
  enabled: Type.Boolean({ default: true }),
3363
- // (F8-54) `bucketRef` was DELETED: the object-storage bucket is a platform
3362
+ // `bucketRef` was DELETED (2026-09-11): the object-storage bucket is a platform
3364
3363
  // binding on files-v1, never tenant-selectable, and no code ever read the
3365
3364
  // leaf — declaring it invited "point files at my own bucket", which vxil does
3366
3365
  // not offer. A persisted manifest that still carries it folds (Value.Clean).
@@ -3369,8 +3368,8 @@ var FilesConfigSchema = Type.Object({
3369
3368
  quotas: Type.Object(
3370
3369
  {
3371
3370
  // maximum caps are a defense-in-depth ceiling on tenant-editable storage —
3372
- // object storage is cheap but the SQL-database-resident metadata + abuse aren't (pricing re-audit
3373
- // 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
3371
+ // object storage is cheap but the database-resident metadata + abuse aren't
3372
+ // (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
3374
3373
  // (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
3375
3374
  // tenant tier threaded to files-v1 (plan tiers).
3376
3375
  maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
@@ -3380,7 +3379,7 @@ var FilesConfigSchema = Type.Object({
3380
3379
  { default: {} }
3381
3380
  ),
3382
3381
  allowedContentTypes: Type.Array(Type.String(), { default: ["*"], maxItems: 100 }),
3383
- // (F8-54) the `contentScan` bag ({enabled, quarantineOnFail}) was DELETED:
3382
+ // (2026-09-11) the `contentScan` bag ({enabled, quarantineOnFail}) was DELETED:
3384
3383
  // there is no malware/content scanner in files-v1 (it was a "V1.5" placeholder
3385
3384
  // neither leaf was ever read), so the knob promised quarantine that never
3386
3385
  // happened. Re-declare it in the same change as a real scanner, not before.
@@ -3391,7 +3390,7 @@ var FilesConfigSchema = Type.Object({
3391
3390
  },
3392
3391
  { default: {} }
3393
3392
  ),
3394
- // Wave-2 extensions (features/files.md §1.1 OCR + §1.2 TTL) — the merge of
3393
+ // Wave-2 extensions (guide ch. 6, files: OCR + TTL) — the merge of
3395
3394
  // workers/files-v1/src/ext.ts FilesExtensionsConfigSchema promised by its
3396
3395
  // 'wiring phase' comment. Each is an OPTIONAL bag (= ONE leaf per the cap
3397
3396
  // rule); files-v1 already reads both defensively (FilesConfigWithExt), so
@@ -3410,8 +3409,8 @@ var FilesConfigSchema = Type.Object({
3410
3409
  [Type.Literal("gcv"), Type.Literal("textract"), Type.Literal("azure-di"), Type.Literal("mock")],
3411
3410
  { default: "mock" }
3412
3411
  ),
3413
- // provider key is BYO + envelope-encrypted in public.tenant_secrets — NOT a
3414
- // config flag. keyRef names the tenant_secrets row (like ai's keyRefs).
3412
+ // provider key is BYO + encrypted in the tenant's secret store — NOT a
3413
+ // config flag. keyRef names the stored secret (like ai's keyRefs).
3415
3414
  keyRef: Type.Optional(Type.String()),
3416
3415
  asyncOverJobs: Type.Boolean({ default: true }),
3417
3416
  // large/multi-page → jobs
@@ -3426,14 +3425,14 @@ var WebhooksConfigSchema = Type.Object({
3426
3425
  enabled: Type.Boolean({ default: true }),
3427
3426
  maxSubscriptions: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
3428
3427
  maxSources: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
3429
- // FAILURE-ALERT DIGEST (P0-2). Outbound subscriptions are the real-time
3428
+ // FAILURE-ALERT DIGEST. Outbound subscriptions are the real-time
3430
3429
  // channel; this is the "nobody is consuming them yet" fallback — a periodic
3431
3430
  // e-mail summary of the tenant's FAILURE-class audit events (the level:
3432
3431
  // 'failure' rows of the generated event catalog). Read by
3433
3432
  // workers/control-plane/src/alertDigest.ts on the minute cron.
3434
3433
  //
3435
- // RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this deviation is
3436
- // journaled here the way RateLimitsConfigSchema journals its own: an
3434
+ // RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this decision is
3435
+ // recorded here the way RateLimitsConfigSchema records its own: an
3437
3436
  // arbitrary `to` would turn vxil's own sending identity into a relay for
3438
3437
  // tenant-authored content and open a PII egress path out of the audit trail.
3439
3438
  // The digest goes to the OWNER-role dashboard accounts of the project (cap
@@ -3443,7 +3442,7 @@ var WebhooksConfigSchema = Type.Object({
3443
3442
  //
3444
3443
  // An Optional object bag counts as ONE leaf (the countLeaves rule).
3445
3444
  //
3446
- // `digestMinutes: 0` is IMMEDIATE (roadmap §4.11 P0-4c, 2026-09-23): the
3445
+ // `digestMinutes: 0` is IMMEDIATE (2026-09-23): the
3447
3446
  // pass runs every minute for the tenant and mails the `error`-level failure
3448
3447
  // rows that landed since its last mail — at most one mail per minute, still
3449
3448
  // to the owner accounts. 1–4 are clamped up to 5 by the reader.
@@ -3452,11 +3451,11 @@ var WebhooksConfigSchema = Type.Object({
3452
3451
  minLevel: Type.Union([Type.Literal("warn"), Type.Literal("error")], { default: "error" }),
3453
3452
  digestMinutes: Type.Integer({ default: 15, minimum: 0, maximum: 1440 })
3454
3453
  })),
3455
- // DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's OUTBOUND
3454
+ // DECLARED API STATE (2026-09-23): the tenant's OUTBOUND
3456
3455
  // subscriptions as config. Keyed by `target_url` — the only stable identity a
3457
3456
  // subscription has (there is no name column). A changed prefix set is an
3458
3457
  // in-place update — PATCH /v1/webhooks/subscriptions/:subId, same sub_id and
3459
- // cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge public.webhook_subscriptions
3458
+ // cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge the live subscriptions
3460
3459
  // onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
3461
3460
  // reported (deleted only under --allow-destructive). Rows on the platform's
3462
3461
  // signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
@@ -3475,10 +3474,10 @@ var CommentsConfigSchema = Type.Object({
3475
3474
  var CmsConfigSchema = Type.Object({
3476
3475
  enabled: Type.Boolean({ default: true }),
3477
3476
  draftPublish: Type.Boolean({ default: true }),
3478
- // cms end-user default-deny fail-safe (path-to-100 §3.2, Feature B). When ON,
3477
+ // cms end-user default-deny fail-safe (guide ch. 4). When ON,
3479
3478
  // a VERIFIED end-user key (owner-scope mode) is DENIED access to any
3480
3479
  // collection that declares no owner_field — `403 server_only` on read AND
3481
- // write (finding 29, 2026-09-20; reads used to be a 404) — instead of the
3480
+ // write (2026-09-20; reads used to be a 404) — instead of the
3482
3481
  // default tenant-wide-shared behavior. Server-caller mode is a
3483
3482
  // byte-for-byte no-op. Default OFF preserves today's shared semantics
3484
3483
  // (owner.int.test.ts's shared-collection invariant). A collection that DOES
@@ -3500,7 +3499,7 @@ var CmsConfigSchema = Type.Object({
3500
3499
  },
3501
3500
  { default: {} }
3502
3501
  ),
3503
- // cms ENRICHMENT (cms.md §6.4): the read-time relation budget. The worker
3502
+ // cms ENRICHMENT (guide ch. 4): the read-time relation budget. The worker
3504
3503
  // clamps via resolveRelationsConfig (enrich.ts) with the SAME defaults +
3505
3504
  // hard ceilings, so an out-of-range value can never widen the bound.
3506
3505
  relations: Type.Object(
@@ -3540,7 +3539,7 @@ var CmsConfigSchema = Type.Object({
3540
3539
  })
3541
3540
  )
3542
3541
  ),
3543
- // Declarative relational read-models (cms-relational-depth §3 B2/B3/B5).
3542
+ // Declarative relational read-models (guide ch. 4).
3544
3543
  // Each is a NAMED, closed-grammar aggregate/rank spec, optionally
3545
3544
  // materialized to a rollup collection on the EXISTING jobs cron (the
3546
3545
  // fn-cron:* reconciler idiom → cms-rollup:* schedules). Grammar is validated
@@ -3551,7 +3550,7 @@ var CmsConfigSchema = Type.Object({
3551
3550
  Type.Object({
3552
3551
  collection: Type.String({ maxLength: 64 }),
3553
3552
  kind: Type.Union([Type.Literal("aggregate"), Type.Literal("rank")]),
3554
- // the §3.1/§4.1 body minus limit — Type.Unknown so Value.Clean keeps it
3553
+ // the aggregate/rank query body minus limit — Type.Unknown so Value.Clean keeps it
3555
3554
  // (the functions `signature` idiom); shape checked by the cross-field rule.
3556
3555
  spec: Type.Unknown(),
3557
3556
  materialize: Type.Optional(Type.Object({
@@ -3588,7 +3587,7 @@ var McpConfigSchema = Type.Object({
3588
3587
  { default: "all" }
3589
3588
  ),
3590
3589
  allowToolList: Type.Optional(Type.Array(Type.String(), { maxItems: 200 })),
3591
- // (F8-54) the `scopedKey.perAgentKeys` leaf was DELETED: it was documented as
3590
+ // (2026-09-11) the `scopedKey.perAgentKeys` leaf was DELETED: it was documented as
3592
3591
  // a "dashboard-UI hint only" that no dashboard ever read, and key minting is a
3593
3592
  // control-plane concern independent of MCP exposure — per-agent keys already
3594
3593
  // work for every tenant, gated by nothing here.
@@ -3603,7 +3602,7 @@ var McpConfigSchema = Type.Object({
3603
3602
  },
3604
3603
  { default: {} }
3605
3604
  ),
3606
- // Tenant-authored CUSTOM tools (mcp.md §6.5): name → tenant-owned https
3605
+ // Tenant-authored CUSTOM tools (guide ch. 10): name → tenant-owned https
3607
3606
  // endpoint. mcp-v1 lists each as `custom_<name>` and POSTs the tool
3608
3607
  // arguments to `url`, HMAC-signed with the per-tenant key from
3609
3608
  // GET /v1/mcp/signing-secret (X-Vxil-Mcp-Signature; the caller's vxil bearer
@@ -3622,7 +3621,7 @@ var McpConfigSchema = Type.Object({
3622
3621
  timeoutMs: Type.Optional(Type.Integer({ minimum: 1e3, maximum: 2e4 }))
3623
3622
  })
3624
3623
  )),
3625
- // Config-declared MCP PROMPTS (mcp.md §11 closure): name → template with
3624
+ // Config-declared MCP PROMPTS (guide ch. 10): name → template with
3626
3625
  // {{placeholder}} interpolation. Served verbatim by mcp-v1 prompts/list +
3627
3626
  // prompts/get. ONE Type.Record leaf; placeholder ↔ arguments consistency is
3628
3627
  // a cross-field rule below.
@@ -3666,9 +3665,9 @@ var ActivityFeedConfigSchema = Type.Object({
3666
3665
  Type.Literal("notification")
3667
3666
  ]),
3668
3667
  aggregation: Type.Optional(Type.String()),
3669
- // group-format rule (§7); required for aggregated/notification
3668
+ // group-format rule; required for aggregated/notification
3670
3669
  ranking: Type.Optional(Type.String())
3671
- // 'chronological' | 'decay' (§8); flat-only; default chronological
3670
+ // 'chronological' | 'decay'; flat-only; default chronological
3672
3671
  }),
3673
3672
  {
3674
3673
  default: {
@@ -3688,7 +3687,7 @@ var ActivityFeedConfigSchema = Type.Object({
3688
3687
  maxFanoutPerJob: Type.Integer({ default: 1e3, minimum: 1, maximum: 1e4 }),
3689
3688
  // follower batch size per jobs task
3690
3689
  maxConcurrentTasks: Type.Integer({ default: 20, minimum: 1, maximum: 1e3 }),
3691
- // per-tenant in-flight cap (LOCAL throttle §5)
3690
+ // per-tenant in-flight cap (local throttle)
3692
3691
  pendingCeiling: Type.Integer({ default: 5e4, minimum: 1 })
3693
3692
  // pending-fan-out-depth back-pressure ceiling
3694
3693
  },
@@ -3703,7 +3702,7 @@ var ActivityFeedConfigSchema = Type.Object({
3703
3702
  },
3704
3703
  { default: {} }
3705
3704
  ),
3706
- // (F8-54) the `aggregation.maxGroupActivities` leaf was DELETED: nothing kept
3705
+ // (2026-09-11) the `aggregation.maxGroupActivities` leaf was DELETED: nothing kept
3707
3706
  // or returned a per-group activity LIST — an aggregated read returns the group
3708
3707
  // rollup (activity_count/actor_count/last_actor), so there was never an N to
3709
3708
  // bound and no code read the leaf. Re-declare it with a group-detail route.
@@ -3717,7 +3716,7 @@ var ActivityFeedConfigSchema = Type.Object({
3717
3716
  crossChannel: Type.Object(
3718
3717
  {
3719
3718
  enabled: Type.Boolean({ default: false }),
3720
- // master gate for the notifications push/email trigger (§10)
3719
+ // master gate for the notifications push/email trigger
3721
3720
  digestCadence: Type.Union(
3722
3721
  [Type.Literal("off"), Type.Literal("hourly"), Type.Literal("daily")],
3723
3722
  { default: "off" }
@@ -3726,7 +3725,7 @@ var ActivityFeedConfigSchema = Type.Object({
3726
3725
  },
3727
3726
  { default: {} }
3728
3727
  )
3729
- // (F8-54) the `rateLimit.addPerSec` leaf was DELETED: activity-feed-v1 has no
3728
+ // (2026-09-11) the `rateLimit.addPerSec` leaf was DELETED: activity-feed-v1 has no
3730
3729
  // rate-limiter binding and never read it, so the declared per-tenant write
3731
3730
  // burst was enforced by nothing (the edge front-door limiter and the
3732
3731
  // per-tenant request meter are the real bounds). Re-declare it together with
@@ -3734,7 +3733,7 @@ var ActivityFeedConfigSchema = Type.Object({
3734
3733
  });
3735
3734
  var VectorSearchConfigSchema = Type.Object({
3736
3735
  enabled: Type.Boolean({ default: true }),
3737
- // 'auto' resolves to the default managed vector backend for the tier (#147).
3736
+ // 'auto' resolves to the default managed vector backend for the tier.
3738
3737
  backend: Type.Union(
3739
3738
  [Type.Literal("auto"), Type.Literal("lakebase"), Type.Literal("pgvector")],
3740
3739
  { default: "auto" }
@@ -3796,7 +3795,7 @@ var VectorSearchConfigSchema = Type.Object({
3796
3795
  // cohere 'rerank-v3.5' / voyage 'rerank-2'
3797
3796
  topN: Type.Optional(Type.Integer({ default: 50, minimum: 1, maximum: 200 })),
3798
3797
  apiKeyRef: Type.Optional(Type.String({ maxLength: 128 }))
3799
- // 'secret:<name>' under KEK_VECTOR_SEARCH
3798
+ // 'secret:<name>' in the tenant's secret store
3800
3799
  })
3801
3800
  ),
3802
3801
  // Config-driven auto-embedding sync from cms collections: the control plane
@@ -3845,7 +3844,7 @@ var AiConfigSchema = Type.Object({
3845
3844
  ],
3846
3845
  { default: "mock" }
3847
3846
  ),
3848
- // BYO keyRefs → public.tenant_secrets (envelope-encrypted). The block is NOT
3847
+ // BYO keyRefs → the tenant's encrypted secret store. The block is NOT
3849
3848
  // optional (the worker declares it plain), so its three optional refs each count
3850
3849
  // as a leaf. The nested blocks carry `default: {}` (this package's convention)
3851
3850
  // so Value.Default materializes them + recurses into the leaf defaults when a
@@ -3857,7 +3856,7 @@ var AiConfigSchema = Type.Object({
3857
3856
  geminiKeyRef: Type.Optional(Type.String()),
3858
3857
  // The openai-compatible extension surface — ONE optional object = ONE config
3859
3858
  // leaf (countLeaves collapses optional objects; 15-leaf cap discipline).
3860
- // compat.openrouterKeyRef: BYO OpenRouter key (tenant_secrets ref, KEK_AI).
3859
+ // compat.openrouterKeyRef: BYO OpenRouter key (a secret-store ref).
3861
3860
  // compat.openaiBaseUrl: point the openai adapter at ANY openai-compatible
3862
3861
  // host (DeepSeek, vLLM, an Azure-compatible proxy). Public-https validated
3863
3862
  // at config WRITE (publicHttpsUrlError below) AND at USE (@vxil/runtime
@@ -3880,7 +3879,7 @@ var AiConfigSchema = Type.Object({
3880
3879
  consumeCredits: Type.Boolean({ default: false })
3881
3880
  // LIVE: reserve→settle against the payments credit ledger (a job-routed generation reserves pre-generation and 402s insufficient_credits)
3882
3881
  }, { default: {} }),
3883
- // `streaming` became an OPTIONAL bag (3 leaves → 1, the M21 `retry` trick)
3882
+ // `streaming` became an OPTIONAL bag (3 leaves → 1, the `retry` trick)
3884
3883
  // on 2026-09-23 to fund the declared `templates[]` below. It KEEPS
3885
3884
  // `default: {}`, so Value.Default still materializes
3886
3885
  // streaming.{enabled,replayBufferFrames,flushMs} into every persisted
@@ -3890,13 +3889,12 @@ var AiConfigSchema = Type.Object({
3890
3889
  streaming: Type.Optional(Type.Object({
3891
3890
  enabled: Type.Boolean({ default: true }),
3892
3891
  replayBufferFrames: Type.Integer({ default: 256, minimum: 1 }),
3893
- // §2a ring-buffer depth
3892
+ // replay ring-buffer depth
3894
3893
  flushMs: Type.Integer({ default: 50, minimum: 0 })
3895
- // §2a/#148 token→frame coalesce window
3894
+ // token→frame coalesce window
3896
3895
  }, { default: {} })),
3897
- // DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's stored
3898
- // prompt templates as config. vxil STORES + versions, never authors (ai.md
3899
- // §0) — declaring them here changes WHO writes the row (the repository, via
3896
+ // DECLARED API STATE (2026-09-23): the tenant's stored
3897
+ // prompt templates as config. vxil STORES + versions, never authors — declaring them here changes WHO writes the row (the repository, via
3900
3898
  // `vxil push`), not what vxil does with it. Converged by CONTENT: the
3901
3899
  // control-plane hashes each declared entry (@vxil/runtime
3902
3900
  // aiTemplateContentSha256) against the `content_sha256` GET /v1/ai/templates
@@ -3905,7 +3903,7 @@ var AiConfigSchema = Type.Object({
3905
3903
  // Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
3906
3904
  // name). Stored templates the config does not declare are reported and left
3907
3905
  // in place — RETIRED (soft: hidden from list + render, history kept) only
3908
- // under --allow-destructive (cvskit F67, 2026-10-01). Bounded to 50 entries: the
3906
+ // under --allow-destructive (2026-10-01). Bounded to 50 entries: the
3909
3907
  // manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
3910
3908
  templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES }))
3911
3909
  });
@@ -3929,7 +3927,7 @@ var RagConfigSchema = Type.Object({
3929
3927
  { default: {} }
3930
3928
  ),
3931
3929
  // declarative per-metadata-field relevance boosts applied in rag AFTER
3932
- // retrieval, BEFORE minScore/budget/grounding (rag.md §2f). ONE Type.Record
3930
+ // retrieval, BEFORE minScore/budget/grounding (guide ch. 6, rag). ONE Type.Record
3933
3931
  // leaf (the activity-feed feedGroups precedent).
3934
3932
  boosts: Type.Record(Type.String(), Type.Union([
3935
3933
  Type.Object({
@@ -3944,13 +3942,13 @@ var RagConfigSchema = Type.Object({
3944
3942
  ]), { default: {} }),
3945
3943
  context: Type.Object(
3946
3944
  {
3947
- // bounded context budget — enforced via the §2c tokenizer, BEFORE the ai call.
3945
+ // bounded context budget — enforced via the tokenizer, BEFORE the ai call.
3948
3946
  maxTokens: Type.Integer({ default: 4e3, minimum: 1, maximum: 1e6 }),
3949
3947
  strategy: Type.Union([Type.Literal("topk"), Type.Literal("mmr")], {
3950
3948
  default: "topk"
3951
3949
  }),
3952
3950
  // 'provider' = the resolved ai provider's tokenizer; 'heuristic' = portable
3953
- // ~chars/4 with a safety margin (§2c, #149).
3951
+ // ~chars/4 with a safety margin.
3954
3952
  tokenizer: Type.Union([Type.Literal("provider"), Type.Literal("heuristic")], {
3955
3953
  default: "provider"
3956
3954
  })
@@ -3976,9 +3974,9 @@ var PaymentsConfigSchema = Type.Object({
3976
3974
  ],
3977
3975
  { default: "mock" }
3978
3976
  ),
3979
- // BYO-key credential blocks → public.tenant_secrets (envelope-encrypted).
3977
+ // BYO-key credential blocks → the tenant's encrypted secret store.
3980
3978
  // Each OPTIONAL object counts as ONE leaf (the tenant's decision is
3981
- // "configure it or not", not each inner ref — features/auth.md §4).
3979
+ // "configure it or not", not each inner ref — guide ch. 6, auth).
3982
3980
  stripe: Type.Optional(Type.Object({
3983
3981
  secretKeyRef: Type.String(),
3984
3982
  webhookSecretRef: Type.String(),
@@ -3993,25 +3991,24 @@ var PaymentsConfigSchema = Type.Object({
3993
3991
  projectId: Type.String(),
3994
3992
  publicSdkKey: Type.String(),
3995
3993
  secretApiKeyRef: Type.String(),
3996
- // Per-tenant webhook secret ref (public.tenant_secrets). Inbound RevenueCat
3994
+ // Per-tenant webhook secret ref (secret store). Inbound RevenueCat
3997
3995
  // webhooks are verified against THIS ref and nothing else: there is no
3998
3996
  // platform-wide PROVIDER_WEBHOOK_SECRET fallback for a real payment provider
3999
- // (that fallback WAS the multi-tenant RC webhook-forgery vector; it is now
4000
- // frozen out by tests/ci/src/provider-webhook-secret-fallback.test.ts, which
3997
+ // (that fallback WAS a cross-tenant webhook-forgery vector; a CI gate now
4001
3998
  // permits `secrets.webhookSecret` only in makeProvider's mock/default arm).
4002
3999
  // Optional at the SCHEMA level only — leaving it unset does not disable
4003
4000
  // verification, it fails CLOSED: every delivery is 401 bad_signature with a
4004
4001
  // `sig_failed` row that can never be reprocessed. Mirrors stripe/paddle
4005
4002
  // webhookSecretRef.
4006
4003
  webhookSecretRef: Type.Optional(Type.String()),
4007
- // Environment integrity (money-path wave F1-4/D5). RevenueCat posts SANDBOX
4004
+ // Environment integrity. RevenueCat posts SANDBOX
4008
4005
  // and PRODUCTION events to the SAME webhook with the same auth header, so a
4009
4006
  // sandbox purchase would otherwise fold into production entitlements. A
4010
4007
  // sandbox event is persisted as outcome 'rejected_environment' (200, never
4011
4008
  // folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
4012
4009
  // environments by signing secret / API base, so only RC carries this knob.
4013
4010
  acceptSandbox: Type.Boolean({ default: false }),
4014
- // (2026-10-01 §4.13 A18) Store-review purchases on a PRODUCTION tenant:
4011
+ // (2026-10-01) Store-review purchases on a PRODUCTION tenant:
4015
4012
  // the reviewer accounts' RevenueCat `app_user_id`s (≤ 20). A SANDBOX event
4016
4013
  // whose subject (and, for a TRANSFER, every source user) is listed here
4017
4014
  // folds — recorded `environment: 'sandbox'` on the delivery, the
@@ -4033,9 +4030,9 @@ var PaymentsConfigSchema = Type.Object({
4033
4030
  // Where a provider-hosted flow sends the payer back (Stripe billing-portal
4034
4031
  // return, PayPal approval return/cancel) — the TENANT's own app URL,
4035
4032
  // https-only. Absent ⇒ the worker's WEB_BASE_URL env (vxil's site), never a
4036
- // hardcoded host (audit 2026-07-10: the old fallback pointed at a dead apex).
4033
+ // hardcoded host (2026-07-10: the old fallback pointed at a dead apex).
4037
4034
  returnUrl: Type.Optional(Type.String({ pattern: "^https://", maxLength: 512 })),
4038
- // NB (F8-54, 2026-09-11): the former `prices.catalogRef` leaf was DELETED —
4035
+ // NB (2026-09-11): the former `prices.catalogRef` leaf was DELETED —
4039
4036
  // it named nothing (prices resolve from ledger.priceMap; no code path ever
4040
4037
  // read it). A persisted manifest that still carries `prices` folds:
4041
4038
  // validateFeatureConfig's Value.Clean strips the stray key.
@@ -4053,11 +4050,11 @@ var PaymentsConfigSchema = Type.Object({
4053
4050
  // product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
4054
4051
  // `boosts` Record-of-Union precedent) with two rule shapes:
4055
4052
  // { creditType, amount, period } a credit grant (the original rule)
4056
- // { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
4053
+ // { tier, durationDays } (2026-09-25) a TIME-BOXED
4057
4054
  // ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
4058
4055
  // below) for `durationDays`, as a charge-linked manual-style row that
4059
4056
  // STACKS behind the user's live same-tier manual rows that have an end
4060
- // (earlier passes AND comp grants since 2026-09-25 F38 — never a row
4057
+ // (earlier passes AND comp grants since 2026-09-25 — never a row
4061
4058
  // linked to the same charge, an open-ended grant or a provider
4062
4059
  // subscription) and is ENDED by that charge's full refund / chargeback.
4063
4060
  // No defaults in either shape, so an existing manifest is
@@ -4066,7 +4063,7 @@ var PaymentsConfigSchema = Type.Object({
4066
4063
  Type.Object({
4067
4064
  creditType: Type.String({ minLength: 1 }),
4068
4065
  amount: Type.Integer({ minimum: 1 }),
4069
- // #128: a grant only ADDS
4066
+ // a grant only ADDS
4070
4067
  period: Type.Union([
4071
4068
  Type.Literal("once"),
4072
4069
  Type.Literal("monthly"),
@@ -4082,15 +4079,15 @@ var PaymentsConfigSchema = Type.Object({
4082
4079
  // tier → entitlement/quota/grant
4083
4080
  entitlements: Type.Array(Type.String()),
4084
4081
  quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })),
4085
- // #128: no negative quota
4082
+ // no negative quota
4086
4083
  rank: Type.Optional(Type.Integer({ minimum: 0 })),
4087
- // precedence for the multi-sub fold (#125)
4084
+ // precedence for the multi-sub fold
4088
4085
  grants: Type.Optional(Type.Array(Type.Object({
4089
4086
  creditType: Type.String({ minLength: 1 }),
4090
4087
  amount: Type.Integer({ minimum: 1 }),
4091
- // #128
4088
+ // a grant only ADDS
4092
4089
  period: Type.String(),
4093
- // (2026-10-01 §4.13 W9) 'add' (the reader's default, today's
4090
+ // (2026-10-01) 'add' (the reader's default, today's
4094
4091
  // behaviour) ADDS `amount` each period; 'reset' makes the period's
4095
4092
  // grant REPLACE what is left: the unspent available balance of
4096
4093
  // `creditType` is written off as one `expire` ledger row and `amount`
@@ -4107,7 +4104,7 @@ var PaymentsConfigSchema = Type.Object({
4107
4104
  priceMap: Type.Optional(Type.Record(Type.String(), Type.String())),
4108
4105
  autoRefundOnJobFailure: Type.Boolean({ default: true }),
4109
4106
  // consume(jobId) reverses on DLQ/timeout
4110
- // Grace window (money-path wave F1-7): a `past_due` subscription stays
4107
+ // Grace window: a `past_due` subscription stays
4111
4108
  // entitled for this many days AFTER its current_period_end (the dunning
4112
4109
  // window the provider is retrying inside). 0 = today's behaviour (a past_due
4113
4110
  // row is never entitled). ONE predicate in the fold — NOT a dunning ladder:
@@ -4115,8 +4112,7 @@ var PaymentsConfigSchema = Type.Object({
4115
4112
  grace: Type.Optional(Type.Object({
4116
4113
  pastDueDays: Type.Integer({ default: 0, minimum: 0, maximum: 90 })
4117
4114
  })),
4118
- // Opt-in period-end enforcement (money-path operations wave, decision D2
4119
- // option a). ABSENT (the default) = today's behaviour: a subscription whose
4115
+ // Opt-in period-end enforcement. ABSENT (the default) = today's behaviour: a subscription whose
4120
4116
  // current_period_end passed with no provider event stays entitled forever
4121
4117
  // (the provider is the only clock). PRESENT = the nightly reconcile sweep
4122
4118
  // flips an `active`/`trialing` row whose current_period_end + slackHours
@@ -4151,21 +4147,21 @@ var PaymentsConfigSchema = Type.Object({
4151
4147
  // (`config.ledger?.unmappedProduct ?? 'error'`), which is the one definition.
4152
4148
  unmappedProduct: Type.Optional(Type.Union([Type.Literal("error"), Type.Literal("ignore")]))
4153
4149
  }))
4154
- // NB (money-path wave F3-19): the former `webhooks.forwardToTenantUrl` leaf
4150
+ // NB: the former `webhooks.forwardToTenantUrl` leaf
4155
4151
  // was DELETED — it had zero readers (never forwarded anything). Outbound
4156
- // delivery of payments state changes rides the audit_event → webhooks-out
4157
- // spine: subscribe to the `payments.` event prefix (payments.md §7b).
4152
+ // delivery of payments state changes rides the audit stream → outbound
4153
+ // webhooks: subscribe to the `payments.` event prefix (guide ch. 6, payments).
4158
4154
  });
4159
4155
  var FunctionsConfigSchema = Type.Object({
4160
4156
  enabled: Type.Boolean({ default: false }),
4161
- // (F8-54) `runtime` ('isolate' | 'container') was DELETED: the container lane
4162
- // is design-only, nothing read the leaf, and accepting 'container' silently
4157
+ // (2026-09-11) `runtime` ('isolate' | 'container') was DELETED: the container lane
4158
+ // is not built, nothing read the leaf, and accepting 'container' silently
4163
4159
  // ran the isolate anyway. It comes back with the lane, not before.
4164
4160
  defaultLimits: Type.Object(
4165
4161
  {
4166
4162
  // cpuMs is the ONLY per-dispatch limit the managed runtime accepts and the
4167
4163
  // only one anything reads (functions-v1 meter.ts + the dispatch cap).
4168
- // (F8-54) `timeoutMs` and `memoryMb` were DELETED: memory is fixed by the
4164
+ // (2026-09-11) `timeoutMs` and `memoryMb` were DELETED: memory is fixed by the
4169
4165
  // runtime and not tenant-selectable, and no wall-clock abort was ever
4170
4166
  // applied — a declared 10s default that nothing enforced.
4171
4167
  cpuMs: Type.Integer({ default: 50, minimum: 5, maximum: 3e5 })
@@ -4221,7 +4217,7 @@ var FunctionsConfigSchema = Type.Object({
4221
4217
  // cmsHook: the CMS collection slug
4222
4218
  event: Type.Optional(Type.String()),
4223
4219
  // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
4224
- // F33 (2026-09-25): the per-binding opt-in to re-delivery on
4220
+ // (2026-09-25) the per-binding opt-in to re-delivery on
4225
4221
  // queue / webhook / cmsHook / authHook (the cross-field rule
4226
4222
  // rejects it on http / cron). Absent = the ACK-200 default. The
4227
4223
  // receiver answers a failed attempt as an enveloped 503 (ladder)
@@ -4252,7 +4248,7 @@ var FunctionsConfigSchema = Type.Object({
4252
4248
  // the settings it was deployed and tested with, not today's. Absent =
4253
4249
  // the legacy settings (@vxil/types FUNCTIONS_RUNTIME_LEGACY). Unbounded
4254
4250
  // strings on purpose (the deploy writes them from the one constant).
4255
- // cpuMs (F2, 2026-10-01): the per-invoke CPU limit the script was
4251
+ // cpuMs (2026-10-01): the per-invoke CPU limit the script was
4256
4252
  // uploaded with (limits.cpu_ms = min(declared limits.cpuMs, the tier's
4257
4253
  // cpuMsPerInvoke, FN_MAX_CPU_MS)) — server-set; the nightly plan pass
4258
4254
  // rewrites it after a tier change. Absent = the platform default.
@@ -4293,10 +4289,9 @@ var FunctionsConfigSchema = Type.Object({
4293
4289
  // egress guard as an outbound parameter. NOT an invocation
4294
4290
  // budget: the INVOCATION is bounded by the platform's own
4295
4291
  // FN_MAX_INVOKE_MS deadline (default >= 5 min), which a
4296
- // bigger per-fetch budget widens with you (audit FN-3).
4297
- // (F8-54's standing "strip the twins" note is DISCHARGED here: `memoryMb`
4298
- // is deleted — memory is fixed by the managed runtime and is not a
4299
- // per-dispatch option; the WfP dispatch bag takes { cpuMs, subRequests }.
4292
+ // bigger per-fetch budget widens with you.
4293
+ // (`memoryMb` is deleted — memory is fixed by the managed runtime and is
4294
+ // not a per-dispatch option; a dispatch takes { cpuMs, subRequests }.
4300
4295
  // Value.Clean strips it from an old config, so such a config still loads
4301
4296
  // and `vxil plan --explain` marks the key DROPPED.)
4302
4297
  limits: Type.Optional(
@@ -4306,7 +4301,7 @@ var FunctionsConfigSchema = Type.Object({
4306
4301
  })
4307
4302
  ),
4308
4303
  enabled: Type.Optional(Type.Boolean()),
4309
- // Level-1 typed I/O (cli-sdk design §4.5): the declared input/output
4304
+ // Level-1 typed I/O (guide ch. 8): the declared input/output
4310
4305
  // contract, persisted by the deploy body so ONLINE `vxil gen` emits the
4311
4306
  // same typed fn client as --offline. Opaque JSON-schema-ish payloads —
4312
4307
  // the CLI's lowerSig lowers them; the platform never interprets them.
@@ -4384,14 +4379,14 @@ var CopilotConfigSchema = Type.Object({
4384
4379
  }),
4385
4380
  { default: {} }
4386
4381
  ),
4387
- // (F8-54) the `escalation` bag ({enabled, handler, notifyTemplate}) was
4382
+ // (2026-09-11) the `escalation` bag ({enabled, handler, notifyTemplate}) was
4388
4383
  // DELETED: the human hand-off it declared was never built — copilot-v1 read
4389
4384
  // none of the three leaves, so a tenant who turned it on got silence. The
4390
4385
  // shipped hand-off path is a tenant function on the conversation events.
4391
4386
  // ── limits: DELEGATE token/credit accounting to ai-v1 ─────────────────────
4392
4387
  limits: Type.Object({
4393
4388
  consumeCredits: Type.Boolean({ default: false })
4394
- // (F8-54) `tokensPerUserPerDay` was DELETED here: token accounting is
4389
+ // (2026-09-11) `tokensPerUserPerDay` was DELETED here: token accounting is
4395
4390
  // delegated to ai-v1 (this bag's own doctrine) and only `ai`'s
4396
4391
  // limits.tokensPerUserPerDay is enforced — the copilot twin read nothing.
4397
4392
  }, { default: {} }),
@@ -4461,9 +4456,9 @@ function lowerAllTriggerBindings(def, name) {
4461
4456
  return bindings;
4462
4457
  }
4463
4458
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
4464
- var VXIL_CONFIG_PKG_VERSION = "0.8.0";
4465
- var VXIL_SDK_PKG_VERSION = "0.13.0";
4466
- var VXIL_CLI_PKG_VERSION = "0.13.0";
4459
+ var VXIL_CONFIG_PKG_VERSION = "0.8.2";
4460
+ var VXIL_SDK_PKG_VERSION = "0.13.1";
4461
+ var VXIL_CLI_PKG_VERSION = "0.13.2";
4467
4462
  function ensureScaffoldPackageJson(cwd, opts = {}) {
4468
4463
  const file = resolve(cwd, "package.json");
4469
4464
  const wanted = {
@@ -6989,7 +6984,7 @@ var KNOWN_SCOPES = [
6989
6984
  "features:read",
6990
6985
  "features:write",
6991
6986
  // Dedicated write scope for the BYO-secret path. PUT /v1/secrets/:feature/:name
6992
- // requires THIS (or 'admin') as of 2026-07-17 (audit H2 — 'features:write' is no
6987
+ // requires THIS (or 'admin') as of 2026-07-17 ('features:write' is no
6993
6988
  // longer secret-write-equivalent). GET/DELETE /v1/secrets still also accept
6994
6989
  // 'features:write'.
6995
6990
  "secrets:write",
@@ -7001,8 +6996,8 @@ var KNOWN_SCOPES = [
7001
6996
  "jobs:write",
7002
6997
  "auth:read",
7003
6998
  "auth:write",
7004
- // Narrow least-privilege scope for the SIGN-IN surface only (2026-09-10,
7005
- // F4-25/F8-56): every auth route an anonymous or self-authenticating client
6999
+ // Narrow least-privilege scope for the SIGN-IN surface only (2026-09-10):
7000
+ // every auth route an anonymous or self-authenticating client
7006
7001
  // must call to obtain, renew, verify or end ITS OWN session (sign-up/sign-in,
7007
7002
  // magic-link, OTP, anonymous, step-up, OAuth, refresh, by-token revoke,
7008
7003
  // sessions/verify, password reset). It does NOT reach the administrative
@@ -8171,14 +8166,14 @@ var FIELD_ATTRS = [
8171
8166
  { config: "compute", wire: "compute", remote: "compute", blank: null, alter: "in-place" },
8172
8167
  { config: "unique", wire: "unique", remote: "is_unique", blank: false, alter: "tighten-only" },
8173
8168
  { config: "onDelete", wire: "on_delete", remote: "on_delete", blank: null, alter: "tighten-only" },
8174
- // cms.md §18 (RB-3) field-level read security. ALTERABLE: cms-v1 rewrites the
8169
+ // Field-level read security (guide ch. 4). ALTERABLE: cms-v1 rewrites the
8175
8170
  // gate in place on a same-type re-POST (the unique/on_delete flag-alter path),
8176
8171
  // so config-as-code can TIGHTEN a gate on an existing field. Blank is `null`
8177
8172
  // (the server stores NULL for "ungated" and normalizes `[]` to NULL), so an
8178
8173
  // omitted attribute diffs clean; CLEARING it WIDENS access and therefore rides
8179
8174
  // the same --allow-destructive gate as clearing unique/on_delete/owner_field.
8180
8175
  { config: "readRoles", wire: "read_roles", remote: "read_roles", blank: null, alter: "tighten-only" },
8181
- // PERF-26b: the equality index of an unslotted field (≤4 per collection).
8176
+ // The equality index of an unslotted field (≤4 per collection).
8182
8177
  // Present-key on the server; arming on > 2,000 live rows runs the re-index.
8183
8178
  { config: "indexed", wire: "indexed", remote: "indexed", blank: false, alter: "index" }
8184
8179
  ];
@@ -10970,19 +10965,25 @@ function apiVersionEntries(input) {
10970
10965
  if (!map2) return [];
10971
10966
  return Object.entries(map2).sort(([a], [b2]) => compareCodePoints(a, b2));
10972
10967
  }
10973
- var API_VERSIONS_BLOCK_HEAD = "\n\n// The API majors this client was generated against (feature-versioning.md \xA7D10).\n// A new major on the backend surfaces here as `vxil gen --check` drift.\nexport const API_VERSIONS = {\n";
10968
+ var API_VERSIONS_BLOCK_HEAD = "\n\n// The API majors this client was generated against.\n// A new major on the backend surfaces here as `vxil gen --check` drift.\nexport const API_VERSIONS = {\n";
10969
+ var LEGACY_API_VERSIONS_HEAD_LINE = /\n\n\/\/ The API majors this client was generated against \([^)\n]*\)\.\n/;
10970
+ function withCurrentApiVersionsHead(src) {
10971
+ return src.replace(LEGACY_API_VERSIONS_HEAD_LINE, "\n\n// The API majors this client was generated against.\n");
10972
+ }
10974
10973
  var API_VERSIONS_BLOCK_TAIL = "\n} as const;";
10975
10974
  function hasApiVersionsBlock(src) {
10976
- return src.includes(API_VERSIONS_BLOCK_HEAD);
10975
+ return withCurrentApiVersionsHead(src).includes(API_VERSIONS_BLOCK_HEAD);
10977
10976
  }
10978
- function stripApiVersionsBlock(src) {
10977
+ function stripApiVersionsBlock(raw) {
10978
+ const src = withCurrentApiVersionsHead(raw);
10979
10979
  const i = src.indexOf(API_VERSIONS_BLOCK_HEAD);
10980
10980
  if (i < 0) return src;
10981
10981
  const j = src.indexOf(API_VERSIONS_BLOCK_TAIL, i + API_VERSIONS_BLOCK_HEAD.length);
10982
10982
  if (j < 0) return src;
10983
10983
  return src.slice(0, i) + src.slice(j + API_VERSIONS_BLOCK_TAIL.length);
10984
10984
  }
10985
- function parseApiVersionsBlock(src) {
10985
+ function parseApiVersionsBlock(raw) {
10986
+ const src = withCurrentApiVersionsHead(raw);
10986
10987
  const i = src.indexOf(API_VERSIONS_BLOCK_HEAD);
10987
10988
  if (i < 0) return null;
10988
10989
  const start = i + API_VERSIONS_BLOCK_HEAD.length;
@@ -11058,7 +11059,7 @@ function typesCheckBody(src) {
11058
11059
  return i >= 0 ? src.slice(i) : src;
11059
11060
  }
11060
11061
  function checkGeneratedTypes(existing, generated, opts) {
11061
- let have = typesCheckBody(existing);
11062
+ let have = typesCheckBody(withCurrentApiVersionsHead(existing));
11062
11063
  let want = typesCheckBody(generated);
11063
11064
  let unpinnedNotice = false;
11064
11065
  if (opts.offline) {
@@ -12396,7 +12397,7 @@ var TOOLS = [
12396
12397
  {
12397
12398
  name: "feeds_unread_count",
12398
12399
  feature: "activity-feed",
12399
- description: "Get a user's notification badge: { unseen, unread, total }. KV-cached over the notification_state authority with recompute-on-miss.",
12400
+ description: "Get a user's notification badge: { unseen, unread, total }. Cached, recomputed on a miss.",
12400
12401
  inputSchema: {
12401
12402
  type: "object",
12402
12403
  properties: {
@@ -12795,7 +12796,7 @@ var TOOLS = [
12795
12796
  },
12796
12797
  {
12797
12798
  // Substrate meta-tool (no `feature` — always-on like users_upsert). Handled
12798
- // LOCALLY in rpc.ts tools/call (mcp.md §6.8) — the `local:` path marker is
12799
+ // LOCALLY in rpc.ts tools/call — the `local:` path marker is
12799
12800
  // never fetched and deliberately non-/v1 so the trinity-drift scanner
12800
12801
  // (extractMcpRoutes only harvests '/v1/…' literals) ignores it: there is no
12801
12802
  // REST route behind this tool.
@@ -14023,7 +14024,7 @@ var VERB_USAGE = {
14023
14024
  version: "usage: vxil --version \u2014 print the CLI version",
14024
14025
  init: "usage: vxil init [--template <id>] [--force] \u2014 scaffold vxil.config.ts + functions/ + .vxil/ from a Blueprint Gallery template (`vxil templates` lists them)",
14025
14026
  templates: "usage: vxil templates \u2014 list the Blueprint Gallery (`vxil init --template <id>` scaffolds one)",
14026
- quickstart: "usage: vxil quickstart [--features a,b] [--invite <code>] [--env <label>] [--dev [--ttl <h>]] [--no-push] \u2014 create a project + key + enable features in one call",
14027
+ quickstart: "usage: vxil quickstart [--email <e>] [--password <p>] [--name <display>] [--features a,b] [--invite <code>] [--env <label>] [--dev [--ttl <h>]] [--no-push] \u2014 create a project + key + enable features in one call",
14027
14028
  try: "usage: vxil try \u2014 a keyless anonymous sandbox backend (no account); self-expires",
14028
14029
  login: "usage: vxil login [--email <e>] [--password <p>] \u2014 store a dashboard session in ~/.vxil (bound to the dashboard it came from)",
14029
14030
  link: "usage: vxil link <slug> [--key <api_key> [--tenant <id>]] [--as dev|<target-name>] [--env <label>] [--mint] \u2014 bind this repo to a project; --tenant is checked against the key's own tenant and refused when the key's tenant cannot be read",
@@ -14032,7 +14033,7 @@ var VERB_USAGE = {
14032
14033
  logout: "usage: vxil logout [--json] \u2014 revoke the stored dashboard session where it was obtained and remove it from ~/.vxil (project API keys are untouched)",
14033
14034
  invites: "usage: vxil invites [list] [--json] \xB7 vxil invites generate [--count <n>] [--json] \xB7 vxil invites accept <token | invite-url> [--json]",
14034
14035
  members: "usage: vxil members [list]|invite|add|rm|uninvite (run `vxil members --help`)",
14035
- projects: "usage: vxil projects [list] [--json] \xB7 projects create <slug> [--name <display>] [--json] \xB7 projects rm <slug> [--yes] [--json]",
14036
+ projects: "usage: vxil projects [list] [--json] \xB7 projects create <slug> [--name <display>] [--workload production|staging|development] [--json] \xB7 projects workload <slug> production|staging|development [--json] \xB7 projects rm <slug> [--yes] [--json]",
14036
14037
  billing: "usage: vxil billing [status] [--json] \xB7 vxil billing upgrade --tier <free|developer|team|business> [--yes] [--json] (upgrade also downgrades: --tier free)",
14037
14038
  files: "usage: vxil files put <path> --user <user_id> [--content-type <type>] [--json] \xB7 vxil files rm <object_id> [--yes] [--json]",
14038
14039
  webhooks: "usage: vxil webhooks sources add --provider <stripe|paddle|github|slack|revenuecat|generic> --name <n> [--forward-url <https>] \xB7 sources list \xB7 sources rm <source_id> [--yes] \xB7 sources test <source_id> \xB7 vxil webhooks events replay <event_id> \xB7 events runs <event_id>",
@@ -14630,7 +14631,7 @@ function inferMapping(source, opts = {}) {
14630
14631
  const pkCols = t.columns.filter((c) => c.isPk);
14631
14632
  const pk = pkCols.length === 1 ? pkCols[0] : void 0;
14632
14633
  if (pkCols.length > 1) {
14633
- warnings.push(`R8: ${t.name} composite PK (${pkCols.map((c) => c.name).join(", ")}) \u2014 synthesize a unique scalar source_id for idempotent re-runs (design \xA79)`);
14634
+ warnings.push(`R8: ${t.name} composite PK (${pkCols.map((c) => c.name).join(", ")}) \u2014 synthesize a unique scalar source_id for idempotent re-runs`);
14634
14635
  }
14635
14636
  const fks = new Map((fksByChild.get(t.name) ?? []).map((fk) => [fk.childColumns[0], fk]));
14636
14637
  const uniqueCols = singleUniques.get(t.name) ?? /* @__PURE__ */ new Set();
@@ -16363,7 +16364,7 @@ async function verifyMigration({ deps, plan, adapter, state }) {
16363
16364
  table,
16364
16365
  source,
16365
16366
  null,
16366
- `${coverage}; count=true unavailable (beforeRead hooks \u2014 the design \xA79 caveat); page the query endpoint to count`
16367
+ `${coverage}; count=true unavailable (beforeRead hooks on this collection); page the query endpoint to count`
16367
16368
  ));
16368
16369
  } else {
16369
16370
  rows.push(row(
@@ -17390,8 +17391,8 @@ var TEMPLATE_CATALOG = [
17390
17391
  ],
17391
17392
  "hasFunctions": false,
17392
17393
  "byoKeys": [],
17393
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Blog\" \u2014 a publishing / headless-CMS backend (authors, categories, posts with\n// a draft\u2192publish lifecycle), declared end-to-end in ONE typed file. A BLUEPRINT\n// composing shipped building blocks \u2014\n// \u2022 cms \u2192 authors \u2192 categories \u2192 posts (resolved by relation)\n// \u2022 comments \u2192 threaded reader comments on posts\n// Everything here is DATA the tenant owns and edits after `vxil init`. The\n// editorial workflow migrates ~100%; the public reader tier is SHIPPED \u2014 `posts`\n// is `public: true`, served keyless over the cms public-delivery lane (cms.md \xA716).\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // the editorial lifecycle: write as draft, publish live\n hooks: {\n // Every post needs a title \u2014 a pure function of the row (Lane-A validate).\n post_title: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.title) > 0',\n message: 'a post needs a title',\n },\n },\n },\n comments: {}, // threaded reader comments on published posts\n notifications: { provider: 'mock', fromEmail: 'noreply@blog.app' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n authors: {\n singular: 'author',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n bio: { type: 'text' },\n },\n },\n categories: {\n singular: 'category',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n posts: {\n singular: 'post',\n // PUBLIC DELIVERY (cms.md \xA716): published posts are readable with NO API\n // key over GET /v1/cms/public/:tenantId/posts \u2014 edge-cached, drafts never\n // served. This is the reader tier of a blog: a static/JAMstack front-end\n // (or the served `listCmsPublic()` SDK helper) fetches the feed anonymously.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', unique: true },\n excerpt: { type: 'text' },\n body: { type: 'text' },\n author: { type: 'relation', relationTo: 'authors', indexSlot: 's3' },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's4' },\n published_at: { type: 'datetime', indexSlot: 't1' },\n reading_minutes: { type: 'int', indexSlot: 'n1' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'authors',\n items: [{ name: 'Ada Lovelace', slug: 'ada', bio: 'Writes about computing.' }],\n },\n ],\n },\n});\n",
17394
- "readme": "# Blog / Publishing template\n\nA publishing / headless-CMS backend \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `authors` \u2014 name, unique slug, bio.\n- `categories` \u2014 name, unique slug.\n- `posts` \u2014 title, unique slug, excerpt, body, `author` + `category` relations (slot-bound for filtering),\n `published_at`, `reading_minutes`. A Lane-A hook requires a title, and `draftPublish` gives you the\n write-as-draft \u2192 publish-live editorial lifecycle. **`public: true`** \u2014 published posts are served over\n the keyless public-delivery lane (see below).\n- `comments` \u2014 threaded reader comments on published posts.\n\n**Use it:**\n\n```bash\nvxil init --template blog # (the CLI resolves @vxil/config itself \u2014 no install needed for the push)\nvxil quickstart --invite <code> # only when the email is new\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The editorial lifecycle is config** \u2014 `draftPublish: true` gives write-as-draft \u2192 `publish`; readers filter\n with the reserved `$status` key (vxil.com/docs/guide/04-data-with-cms).\n- **Slot-bound relations are the join declaration** \u2014 `posts.author`/`posts.category` ride `s3`/`s4`, so a feed\n filters by author directly, or reaches one hop into the target: `?filter={\"category.slug\":\"news\"}` is the\n \xA712.1 single-hop dotted-key join.\n- **Invariants ride as tenant-owned Lane-A hooks** \u2014 the \"posts need a title\" rule is data in YOUR config, not\n platform code (\xA77).\n\n```ts\n// the reader feed with a server key: published posts, newest first\nconst { items, next_cursor } = await vx.from('posts').query({\n filter: { $status: 'published' }, sort: '-published_at', limit: 25,\n});\n```\n\n**The public reader tier \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `posts` is `public: true`, a\nstatic/JAMstack front-end reads the feed with **no API key**: the edge mints a restricted read-only token,\nforces `status = 'published'` (drafts are never served), and edge-caches the response\n(`s-maxage=60`, `stale-while-revalidate`).\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public feed \u2014 NO api key, no Vxil client, no auth\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-published_at', limit: 25 });\n// one post by slug (a draft slug 404s to the reader)\nconst { items: [post] } = await listCmsPublic('ten_your_tenant_id', 'posts', { filter: { slug: 'hello' }, limit: 1 });\n```\n\n**Reader comments UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a post\npage for the threaded `comments` feature \u2014 a pure client-side component over the shipped comments API.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/06-feature-catalog (comments) \xB7 `templates/docs-site/` (a pure public-content site) \xB7\n`templates/catalog/` (the same shape for products) \xB7 `examples/feedback-board/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
17394
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Blog\" \u2014 a publishing / headless-CMS backend (authors, categories, posts with\n// a draft\u2192publish lifecycle), declared end-to-end in ONE typed file. A BLUEPRINT\n// composing shipped building blocks \u2014\n// \u2022 cms \u2192 authors \u2192 categories \u2192 posts (resolved by relation)\n// \u2022 comments \u2192 threaded reader comments on posts\n// Everything here is DATA the tenant owns and edits after `vxil init`. The\n// editorial workflow migrates ~100%; the public reader tier is SHIPPED \u2014 `posts`\n// is `public: true`, served keyless over the cms public-delivery lane (guide ch. 4, public delivery).\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // the editorial lifecycle: write as draft, publish live\n hooks: {\n // Every post needs a title \u2014 a pure function of the row (Lane-A validate).\n post_title: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.title) > 0',\n message: 'a post needs a title',\n },\n },\n },\n comments: {}, // threaded reader comments on published posts\n notifications: { provider: 'mock', fromEmail: 'noreply@blog.app' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n authors: {\n singular: 'author',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n bio: { type: 'text' },\n },\n },\n categories: {\n singular: 'category',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n posts: {\n singular: 'post',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): published posts are readable with NO API\n // key over GET /v1/cms/public/:tenantId/posts \u2014 edge-cached, drafts never\n // served. This is the reader tier of a blog: a static/JAMstack front-end\n // (or the served `listCmsPublic()` SDK helper) fetches the feed anonymously.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', unique: true },\n excerpt: { type: 'text' },\n body: { type: 'text' },\n author: { type: 'relation', relationTo: 'authors', indexSlot: 's3' },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's4' },\n published_at: { type: 'datetime', indexSlot: 't1' },\n reading_minutes: { type: 'int', indexSlot: 'n1' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'authors',\n items: [{ name: 'Ada Lovelace', slug: 'ada', bio: 'Writes about computing.' }],\n },\n ],\n },\n});\n",
17395
+ "readme": "# Blog / Publishing template\n\nA publishing / headless-CMS backend \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `authors` \u2014 name, unique slug, bio.\n- `categories` \u2014 name, unique slug.\n- `posts` \u2014 title, unique slug, excerpt, body, `author` + `category` relations (slot-bound for filtering),\n `published_at`, `reading_minutes`. A Lane-A hook requires a title, and `draftPublish` gives you the\n write-as-draft \u2192 publish-live editorial lifecycle. **`public: true`** \u2014 published posts are served over\n the keyless public-delivery lane (see below).\n- `comments` \u2014 threaded reader comments on published posts.\n\n**Use it:**\n\n```bash\nvxil init --template blog # (the CLI resolves @vxil/config itself \u2014 no install needed for the push)\nvxil quickstart --env staging --invite <code> # only when the email is new\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The editorial lifecycle is config** \u2014 `draftPublish: true` gives write-as-draft \u2192 `publish`; readers filter\n with the reserved `$status` key (vxil.com/docs/guide/04-data-with-cms).\n- **Slot-bound relations are the join declaration** \u2014 `posts.author`/`posts.category` ride `s3`/`s4`, so a feed\n filters by author directly, or reaches one hop into the target: `?filter={\"category.slug\":\"news\"}` is the\n single-hop dotted-key join (guide ch. 4, relational depth).\n- **Invariants ride as tenant-owned Lane-A hooks** \u2014 the \"posts need a title\" rule is data in YOUR config, not\n platform code (guide ch. 7).\n\n```ts\n// the reader feed with a server key: published posts, newest first\nconst { items, next_cursor } = await vx.from('posts').query({\n filter: { $status: 'published' }, sort: '-published_at', limit: 25,\n});\n```\n\n**The public reader tier \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `posts` is `public: true`, a\nstatic/JAMstack front-end reads the feed with **no API key**: the edge mints a restricted read-only token,\nforces `status = 'published'` (drafts are never served), and edge-caches the response\n(`s-maxage=60`, `stale-while-revalidate`).\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public feed \u2014 NO api key, no Vxil client, no auth\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-published_at', limit: 25 });\n// one post by slug (a draft slug 404s to the reader)\nconst { items: [post] } = await listCmsPublic('ten_your_tenant_id', 'posts', { filter: { slug: 'hello' }, limit: 1 });\n```\n\n**Reader comments UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a post\npage for the threaded `comments` feature \u2014 a pure client-side component over the shipped comments API.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/06-feature-catalog (comments) \xB7 `templates/docs-site/` (a pure public-content site) \xB7\n`templates/catalog/` (the same shape for products) \xB7 `examples/feedback-board/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
17395
17396
  "functions": {}
17396
17397
  },
17397
17398
  {
@@ -17412,8 +17413,8 @@ var TEMPLATE_CATALOG = [
17412
17413
  ],
17413
17414
  "hasFunctions": false,
17414
17415
  "byoKeys": [],
17415
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Microblog\" \u2014 a Twitter-style micro-blogging backend (profiles, 280-char\n// posts, follows, likes, a realtime live feed), declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 profiles \u2192 posts (by relation) + follows/likes edge rows\n// \u2022 auth \u2192 accounts, so a poster is a VERIFIED end-user\n// \u2022 realtime \u2192 the live feed channel (posts fan out via the cms `cdc` bridge)\n// Every collection declares an end-user OWNER field, so from a thin client a\n// signed-in user can only write their OWN rows; tenant-wide reads (the global\n// timeline, follower counts) are served by YOUR backend with a server key \u2014\n// owner-scoping is a no-op for server callers (vxil.com/docs/guide/04-data-with-cms).\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // create posts with `status: 'published'` \u2014 no editorial step\n // Fail-safe (cms.md \xA715.1): a verified end-user key may only touch\n // collections that declare an ownerField. Every collection below does;\n // any collection you ADD later without one is denied to end-user keys\n // instead of silently shared tenant-wide. Server keys are unaffected.\n strictEndUserScope: true,\n // Lane-A safe-expression hooks \u2014 AST-validated at push time, run inside\n // the write transaction (cms.md \xA77). NOTE: hooks do NOT run on `$inc`\n // (\xA79.3) \u2014 likes_count is guarded by its own validation.min instead.\n hooks: {\n post_body_nonempty: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.body)) > 0',\n message: 'a post cannot be empty',\n },\n post_body_280: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.body) <= 280',\n message: 'a post is at most 280 characters',\n },\n // Field-vs-field comparison is grammar-legal (\xA77.2 operators over item.*).\n follow_not_self: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'item.follower != item.followee',\n message: 'you cannot follow yourself',\n },\n // COMPOSED-KEY integrity: `pair` is written by the client/SDK as\n // follower + ':' + followee (see the field comment). This hook makes\n // that convention server-enforced, so the `unique: true` claim on\n // `pair` really means \"at most one follow edge per (follower,\n // followee)\" \u2014 a double-follow is a clean 409 unique_violation.\n follow_pair: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.follower, ':', item.followee)\",\n message: \"pair must be follower + ':' + followee\",\n },\n like_pair: {\n collection: 'likes',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.post, ':', item.user_id)\",\n message: \"pair must be post + ':' + user_id\",\n },\n },\n // Realtime CDC bridge (cms.md \xA714): every NEW post auto-publishes a\n // `cms.item.created` / `.published` frame (this rule's two events \u2014\n // updates/deletes don't fire it) \u2014 full item data, \u226432KB \u2014 onto the\n // realtime channel below. The config-only live feed: at-most-once,\n // fire-and-forget (guaranteed delivery would use webhooks or functions).\n cdc: {\n feed_live: {\n collection: 'posts',\n channel: 'feed:global',\n events: ['created', 'published'],\n payload: 'full',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // posters sign in as end-users\n realtime: {}, // defaults are fine; a channel exists as soon as someone uses it\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n profiles: {\n singular: 'profile',\n // End-user owner-scope (cms.md \xA715): a signed-in user edits only their\n // OWN profile. `user_id` must be a real string field (declared below).\n ownerField: 'user_id',\n // PUBLIC DELIVERY (cms.md \xA716): public profile pages read with NO API key\n // over GET /v1/cms/public/:tenantId/profiles \u2014 edge-cached, and the\n // `user_id` owner field is STRIPPED from every served row (an anonymous\n // reader never sees the end-user id). Owner-scoping (above) still governs\n // the authed WRITE lane; public delivery is a read-only, owner-unscoped tier.\n public: true,\n fields: {\n // Declarative uniqueness (cms.md \xA79.3), carried by `vxil push`: N\n // racing claims of a handle yield exactly one 201, the rest a clean\n // 409 unique_violation \u2014 the insert IS the claim. (Flipping the flag\n // on an ALREADY-pushed field is a dashboard/REST in-place alter \u2014\n // push's diff compares field name+type only.)\n handle: { type: 'string', required: true, indexSlot: 's1', unique: true },\n display_name: { type: 'string', indexSlot: 's2' },\n bio: { type: 'text' },\n user_id: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n },\n },\n posts: {\n singular: 'post',\n ownerField: 'user_id',\n // PUBLIC DELIVERY (cms.md \xA716): the GLOBAL TIMELINE served with NO API key\n // over GET /v1/cms/public/:tenantId/posts?sort=-posted_at \u2014 edge-cached,\n // published-only, `user_id` stripped from every row. This is exactly the\n // \"tenant-wide reads served by YOUR backend\" note above, but now keyless:\n // an anonymous visitor reads the public feed without your server key.\n public: true,\n fields: {\n body: { type: 'text', required: true }, // \u2264280 chars \u2014 enforced by the hooks above\n author: { type: 'relation', relationTo: 'profiles', indexSlot: 's1' },\n user_id: { type: 'string', indexSlot: 's2' }, // the owner (end-user) id\n posted_at: { type: 'datetime', indexSlot: 't1' }, // slot t1 \u21D2 sort=-posted_at is index-served\n // Bumped atomically via PATCH {\"$inc\":{\"likes_count\":1}} (cms.md \xA79.3);\n // min:0 turns a decrement below zero into a clean 409, never a race.\n likes_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n },\n },\n follows: {\n singular: 'follow',\n // Owner = the follower: an end-user creates/removes only their OWN edges.\n ownerField: 'follower',\n fields: {\n follower: { type: 'string', required: true, indexSlot: 's1' }, // end-user id\n followee: { type: 'string', required: true, indexSlot: 's2' }, // end-user id\n // COMPOSED KEY \u2014 cms `unique` is single-field, so composite uniqueness\n // is modeled by having the client/SDK write follower + ':' + followee\n // here; the `follow_pair` hook rejects a mismatched composition, and\n // `unique: true` (cms.md \xA79.3) makes a double-follow a clean 409\n // unique_violation on a plain create \u2014 no lock/guard needed for\n // pair dedup. (lock+guard, \xA710, stays the tool for count-invariants\n // BEYOND uniqueness \u2014 e.g. \"at most N\".)\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n likes: {\n singular: 'like',\n ownerField: 'user_id',\n fields: {\n post: { type: 'relation', relationTo: 'posts', required: true, indexSlot: 's1' },\n user_id: { type: 'string', required: true, indexSlot: 's2' }, // the owner (end-user) id\n // COMPOSED KEY \u2014 post + ':' + user_id, declared unique: one like per\n // user per post; a double-like is a clean 409 unique_violation.\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'profiles',\n items: [\n { handle: 'ada', display_name: 'Ada Lovelace', bio: 'Notes on engines, in 280 chars.', user_id: 'usr_demo_ada' },\n { handle: 'grace', display_name: 'Grace Hopper', bio: 'Compilers, ships, short posts.', user_id: 'usr_demo_grace' },\n ],\n },\n {\n // The `author` relation is omitted here: seed items are plain creates and\n // cannot reference the server-generated item_id of the profiles above \u2014\n // set it on posts your app creates at runtime. Seeded items land as\n // drafts; publish them from the dashboard, or create real posts with\n // `status: 'published'` (see README).\n collection: 'posts',\n items: [\n { body: 'Hello, world \u2014 first post on my own backend.', user_id: 'usr_demo_ada', posted_at: '2026-07-01T09:00:00Z', likes_count: 0 },\n { body: 'A microblog is just cms + auth + realtime in one config file.', user_id: 'usr_demo_grace', posted_at: '2026-07-01T09:05:00Z', likes_count: 0 },\n ],\n },\n ],\n },\n});\n",
17416
- "readme": "# Microblog / Social Feed template\n\nA Twitter-style micro-blogging backend \u2014 profiles, 280-character posts, follows, likes, and a realtime\nlive feed \u2014 declared end-to-end in one typed `vxil.config.ts`. Backend building blocks you enable in one\nline: `cms` holds the data (Lane-A hooks enforce the 280-char rule inside the write transaction), `auth`\nmakes every poster a verified end-user, and `realtime` streams new posts to every open client.\n\n**What it provisions:**\n- `profiles` \u2014 unique `handle`, display name, bio; owner-scoped by `user_id`. **`public: true`** \u2014 public\n profile pages read keyless (the `user_id` owner field is stripped from every served row).\n- `posts` \u2014 `body` (two validate hooks: non-empty, \u2264280), `author` relation \u2192 profiles, `posted_at`,\n atomic `likes_count`; owner-scoped by `user_id`. **`public: true`** \u2014 the global timeline reads keyless.\n- `follows` \u2014 `follower`/`followee` + a composed unique `pair` key; hooks block self-follows and a malformed pair.\n- `likes` \u2014 `post` relation + `user_id` + a composed unique `pair` key (one like per user per post).\n- Features: `cms` (with `strictEndUserScope` + a `cdc` live-feed rule), `auth` (email/password), `realtime`.\n\n**Apply it:**\n\n```bash\nvxil init --template microblog\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n1. **End-user owner scoping** (vxil.com/docs/guide/04-data-with-cms) \u2014 every collection names an `ownerField`, so a\n signed-in user can only write their OWN rows from a thin client; `strictEndUserScope: true` fail-closes\n any collection you later forget to scope. Server keys are unaffected \u2014 the global timeline and\n follower counts are served by your backend, where owner-scoping is a no-op.\n2. **Composed-key uniqueness** \u2014 cms `unique` is single-field, so \"unique (follower, followee)\" is modeled\n as a `pair` field the client writes as `follower + ':' + followee`; a validate hook enforces the\n composition, and the declarative `unique: true` on `pair` (in the config, carried by `vxil push` \u2014\n `cms.md` \xA79.3) turns a double-follow/double-like into a clean `409 unique_violation` on a plain\n create \u2014 no lock needed. (`lock` + `guard` (`cms.md` \xA710) remains the general tool for\n count-invariants BEYOND uniqueness, e.g. \"at most N seats/redemptions\".)\n3. **Atomic counters** (`cms.md` \xA79.3) \u2014 `likes_count` bumps via `$inc`: ONE conditional UPDATE, no\n read-modify-write race; `validation.min: 0` refuses a decrement below zero.\n4. **Realtime live feed** (`cms.md` \xA714) \u2014 the `cdc` rule auto-publishes every new post (its\n `created`/`published` events) onto the `feed:global` channel; browsers subscribe with\n `@vxil/realtime` (vxil.com/docs/guide/06-feature-catalog: realtime).\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nimport type { VxilSchema } from './vxil.types';\nconst vx = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! });\n\n// post \u2014 the hooks reject empty or >280 bodies inside the write tx\nconst { item_id: post } = await vx.from('posts').create(\n { body: 'hello from my own backend', user_id: 'usr_demo_ada',\n posted_at: new Date().toISOString(), likes_count: 0 },\n { status: 'published' }, // also fires the cdc frame onto feed:global\n);\n// like it \u2014 a plain create: the unique claim on `pair` makes a double-like a clean 409 unique_violation \u2026\nconst pair = `${post}:usr_demo_grace`;\nawait vx.from('likes').create({ post, user_id: 'usr_demo_grace', pair });\n// \u2026 and the counter bumps atomically, no read-modify-write (cms.md \xA79.3)\nawait vx.from('posts').inc(post, { likes_count: 1 });\n```\n\n**The public timeline \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `posts` and `profiles` are\n`public: true`, an anonymous visitor reads the global feed and public profile pages with **no API key** \u2014\nthe edge forces `status = 'published'`, edge-caches the page, and **strips the `user_id` owner field** from\nevery row (an anonymous reader never sees an end-user id). Owner-scoping still governs every authed WRITE:\nthe two lanes are independent. (`follows`/`likes` stay private \u2014 no `public` flag.)\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public global timeline \u2014 NO api key, user_id stripped from every row\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-posted_at', limit: 25 });\n// a public profile by handle\nconst { items: [p] } = await listCmsPublic('ten_your_tenant_id', 'profiles', { filter: { handle: 'ada' }, limit: 1 });\n```\n\n**Drop-in UI (optional):** [`@vxil/realtime`](../../packages/realtime) is the browser client for the live\n`feed:global` channel; [`@vxil/react/feed`](../../packages/react) is a React timeline +\nnotification-bell surface over the shipped `activity-feed` API for a follow-graph home feed. (The React\ncomponents ship via npm into a bundled app \u2014 there is no served `.mjs` for them, unlike `@vxil/realtime`.)\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (owner-scoping, **public delivery**), vxil.com/docs/guide/07-validation-and-hooks,\nvxil.com/docs/api (lock/guard on the item write routes), vxil.com/docs/guide/06-feature-catalog (realtime + the `@vxil/realtime` browser client,\nauth), and `examples/ecommerce/` for the same patterns composed with functions.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
17416
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Microblog\" \u2014 a Twitter-style micro-blogging backend (profiles, 280-char\n// posts, follows, likes, a realtime live feed), declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 profiles \u2192 posts (by relation) + follows/likes edge rows\n// \u2022 auth \u2192 accounts, so a poster is a VERIFIED end-user\n// \u2022 realtime \u2192 the live feed channel (posts fan out via the cms `cdc` bridge)\n// Every collection declares an end-user OWNER field, so from a thin client a\n// signed-in user can only write their OWN rows; tenant-wide reads (the global\n// timeline, follower counts) are served by YOUR backend with a server key \u2014\n// owner-scoping is a no-op for server callers (vxil.com/docs/guide/04-data-with-cms).\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // create posts with `status: 'published'` \u2014 no editorial step\n // Fail-safe (guide ch. 4, ownerField): a verified end-user key may only touch\n // collections that declare an ownerField. Every collection below does;\n // any collection you ADD later without one is denied to end-user keys\n // instead of silently shared tenant-wide. Server keys are unaffected.\n strictEndUserScope: true,\n // Lane-A safe-expression hooks \u2014 AST-validated at push time, run inside\n // the write transaction (guide ch. 7). NOTE: hooks do NOT run on `$inc`\n // (guide ch. 4, uniqueness) \u2014 likes_count is guarded by its own validation.min instead.\n hooks: {\n post_body_nonempty: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.body)) > 0',\n message: 'a post cannot be empty',\n },\n post_body_280: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.body) <= 280',\n message: 'a post is at most 280 characters',\n },\n // Field-vs-field comparison is grammar-legal (guide ch. 7 operators over item.*).\n follow_not_self: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'item.follower != item.followee',\n message: 'you cannot follow yourself',\n },\n // COMPOSED-KEY integrity: `pair` is written by the client/SDK as\n // follower + ':' + followee (see the field comment). This hook makes\n // that convention server-enforced, so the `unique: true` claim on\n // `pair` really means \"at most one follow edge per (follower,\n // followee)\" \u2014 a double-follow is a clean 409 unique_violation.\n follow_pair: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.follower, ':', item.followee)\",\n message: \"pair must be follower + ':' + followee\",\n },\n like_pair: {\n collection: 'likes',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.post, ':', item.user_id)\",\n message: \"pair must be post + ':' + user_id\",\n },\n },\n // Realtime CDC bridge (guide ch. 4, keeping clients in sync): every NEW post auto-publishes a\n // `cms.item.created` / `.published` frame (this rule's two events \u2014\n // updates/deletes don't fire it) \u2014 full item data, \u226432KB \u2014 onto the\n // realtime channel below. The config-only live feed: at-most-once,\n // fire-and-forget (guaranteed delivery would use webhooks or functions).\n cdc: {\n feed_live: {\n collection: 'posts',\n channel: 'feed:global',\n events: ['created', 'published'],\n payload: 'full',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // posters sign in as end-users\n realtime: {}, // defaults are fine; a channel exists as soon as someone uses it\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n profiles: {\n singular: 'profile',\n // End-user owner-scope (guide ch. 4, ownerField): a signed-in user edits only their\n // OWN profile. `user_id` must be a real string field (declared below).\n ownerField: 'user_id',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): public profile pages read with NO API key\n // over GET /v1/cms/public/:tenantId/profiles \u2014 edge-cached, and the\n // `user_id` owner field is STRIPPED from every served row (an anonymous\n // reader never sees the end-user id). Owner-scoping (above) still governs\n // the authed WRITE lane; public delivery is a read-only, owner-unscoped tier.\n public: true,\n fields: {\n // Declarative uniqueness (guide ch. 4, uniqueness), carried by `vxil push`: N\n // racing claims of a handle yield exactly one 201, the rest a clean\n // 409 unique_violation \u2014 the insert IS the claim. (Flipping the flag\n // on an ALREADY-pushed field is a dashboard/REST in-place alter \u2014\n // push's diff compares field name+type only.)\n handle: { type: 'string', required: true, indexSlot: 's1', unique: true },\n display_name: { type: 'string', indexSlot: 's2' },\n bio: { type: 'text' },\n user_id: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n },\n },\n posts: {\n singular: 'post',\n ownerField: 'user_id',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the GLOBAL TIMELINE served with NO API key\n // over GET /v1/cms/public/:tenantId/posts?sort=-posted_at \u2014 edge-cached,\n // published-only, `user_id` stripped from every row. This is exactly the\n // \"tenant-wide reads served by YOUR backend\" note above, but now keyless:\n // an anonymous visitor reads the public feed without your server key.\n public: true,\n fields: {\n body: { type: 'text', required: true }, // \u2264280 chars \u2014 enforced by the hooks above\n author: { type: 'relation', relationTo: 'profiles', indexSlot: 's1' },\n user_id: { type: 'string', indexSlot: 's2' }, // the owner (end-user) id\n posted_at: { type: 'datetime', indexSlot: 't1' }, // slot t1 \u21D2 sort=-posted_at is index-served\n // Bumped atomically via PATCH {\"$inc\":{\"likes_count\":1}} (guide ch. 4, uniqueness);\n // min:0 turns a decrement below zero into a clean 409, never a race.\n likes_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n },\n },\n follows: {\n singular: 'follow',\n // Owner = the follower: an end-user creates/removes only their OWN edges.\n ownerField: 'follower',\n fields: {\n follower: { type: 'string', required: true, indexSlot: 's1' }, // end-user id\n followee: { type: 'string', required: true, indexSlot: 's2' }, // end-user id\n // COMPOSED KEY \u2014 cms `unique` is single-field, so composite uniqueness\n // is modeled by having the client/SDK write follower + ':' + followee\n // here; the `follow_pair` hook rejects a mismatched composition, and\n // `unique: true` (guide ch. 4, uniqueness) makes a double-follow a clean 409\n // unique_violation on a plain create \u2014 no lock/guard needed for\n // pair dedup. (lock+guard, guide ch. 4, stays the tool for count-invariants\n // BEYOND uniqueness \u2014 e.g. \"at most N\".)\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n likes: {\n singular: 'like',\n ownerField: 'user_id',\n fields: {\n post: { type: 'relation', relationTo: 'posts', required: true, indexSlot: 's1' },\n user_id: { type: 'string', required: true, indexSlot: 's2' }, // the owner (end-user) id\n // COMPOSED KEY \u2014 post + ':' + user_id, declared unique: one like per\n // user per post; a double-like is a clean 409 unique_violation.\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'profiles',\n items: [\n { handle: 'ada', display_name: 'Ada Lovelace', bio: 'Notes on engines, in 280 chars.', user_id: 'usr_demo_ada' },\n { handle: 'grace', display_name: 'Grace Hopper', bio: 'Compilers, ships, short posts.', user_id: 'usr_demo_grace' },\n ],\n },\n {\n // The `author` relation is omitted here: seed items are plain creates and\n // cannot reference the server-generated item_id of the profiles above \u2014\n // set it on posts your app creates at runtime. Seeded items land as\n // drafts; publish them from the dashboard, or create real posts with\n // `status: 'published'` (see README).\n collection: 'posts',\n items: [\n { body: 'Hello, world \u2014 first post on my own backend.', user_id: 'usr_demo_ada', posted_at: '2026-07-01T09:00:00Z', likes_count: 0 },\n { body: 'A microblog is just cms + auth + realtime in one config file.', user_id: 'usr_demo_grace', posted_at: '2026-07-01T09:05:00Z', likes_count: 0 },\n ],\n },\n ],\n },\n});\n",
17417
+ "readme": "# Microblog / Social Feed template\n\nA Twitter-style micro-blogging backend \u2014 profiles, 280-character posts, follows, likes, and a realtime\nlive feed \u2014 declared end-to-end in one typed `vxil.config.ts`. Backend building blocks you enable in one\nline: `cms` holds the data (Lane-A hooks enforce the 280-char rule inside the write transaction), `auth`\nmakes every poster a verified end-user, and `realtime` streams new posts to every open client.\n\n**What it provisions:**\n- `profiles` \u2014 unique `handle`, display name, bio; owner-scoped by `user_id`. **`public: true`** \u2014 public\n profile pages read keyless (the `user_id` owner field is stripped from every served row).\n- `posts` \u2014 `body` (two validate hooks: non-empty, \u2264280), `author` relation \u2192 profiles, `posted_at`,\n atomic `likes_count`; owner-scoped by `user_id`. **`public: true`** \u2014 the global timeline reads keyless.\n- `follows` \u2014 `follower`/`followee` + a composed unique `pair` key; hooks block self-follows and a malformed pair.\n- `likes` \u2014 `post` relation + `user_id` + a composed unique `pair` key (one like per user per post).\n- Features: `cms` (with `strictEndUserScope` + a `cdc` live-feed rule), `auth` (email/password), `realtime`.\n\n**Apply it:**\n\n```bash\nvxil init --template microblog\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n1. **End-user owner scoping** (vxil.com/docs/guide/04-data-with-cms) \u2014 every collection names an `ownerField`, so a\n signed-in user can only write their OWN rows from a thin client; `strictEndUserScope: true` fail-closes\n any collection you later forget to scope. Server keys are unaffected \u2014 the global timeline and\n follower counts are served by your backend, where owner-scoping is a no-op.\n2. **Composed-key uniqueness** \u2014 cms `unique` is single-field, so \"unique (follower, followee)\" is modeled\n as a `pair` field the client writes as `follower + ':' + followee`; a validate hook enforces the\n composition, and the declarative `unique: true` on `pair` (in the config, carried by `vxil push` \u2014\n guide ch. 4, uniqueness) turns a double-follow/double-like into a clean `409 unique_violation` on a plain\n create \u2014 no lock needed. (`lock` + `guard` (guide ch. 4, preconditions and concurrency) remains the general tool for\n count-invariants BEYOND uniqueness, e.g. \"at most N seats/redemptions\".)\n3. **Atomic counters** (guide ch. 4, uniqueness) \u2014 `likes_count` bumps via `$inc`: ONE conditional UPDATE, no\n read-modify-write race; `validation.min: 0` refuses a decrement below zero.\n4. **Realtime live feed** (guide ch. 4, keeping clients in sync) \u2014 the `cdc` rule auto-publishes every new post (its\n `created`/`published` events) onto the `feed:global` channel; browsers subscribe with\n `@vxil/realtime` (vxil.com/docs/guide/06-feature-catalog: realtime).\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nimport type { VxilSchema } from './vxil.types';\nconst vx = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! });\n\n// post \u2014 the hooks reject empty or >280 bodies inside the write tx\nconst { item_id: post } = await vx.from('posts').create(\n { body: 'hello from my own backend', user_id: 'usr_demo_ada',\n posted_at: new Date().toISOString(), likes_count: 0 },\n { status: 'published' }, // also fires the cdc frame onto feed:global\n);\n// like it \u2014 a plain create: the unique claim on `pair` makes a double-like a clean 409 unique_violation \u2026\nconst pair = `${post}:usr_demo_grace`;\nawait vx.from('likes').create({ post, user_id: 'usr_demo_grace', pair });\n// \u2026 and the counter bumps atomically, no read-modify-write (guide ch. 4, uniqueness)\nawait vx.from('posts').inc(post, { likes_count: 1 });\n```\n\n**The public timeline \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `posts` and `profiles` are\n`public: true`, an anonymous visitor reads the global feed and public profile pages with **no API key** \u2014\nthe edge forces `status = 'published'`, edge-caches the page, and **strips the `user_id` owner field** from\nevery row (an anonymous reader never sees an end-user id). Owner-scoping still governs every authed WRITE:\nthe two lanes are independent. (`follows`/`likes` stay private \u2014 no `public` flag.)\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public global timeline \u2014 NO api key, user_id stripped from every row\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-posted_at', limit: 25 });\n// a public profile by handle\nconst { items: [p] } = await listCmsPublic('ten_your_tenant_id', 'profiles', { filter: { handle: 'ada' }, limit: 1 });\n```\n\n**Drop-in UI (optional):** [`@vxil/realtime`](../../packages/realtime) is the browser client for the live\n`feed:global` channel; [`@vxil/react/feed`](../../packages/react) is a React timeline +\nnotification-bell surface over the shipped `activity-feed` API for a follow-graph home feed. (The React\ncomponents ship via npm into a bundled app \u2014 there is no served `.mjs` for them, unlike `@vxil/realtime`.)\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (owner-scoping, **public delivery**), vxil.com/docs/guide/07-validation-and-hooks,\nvxil.com/docs/api (lock/guard on the item write routes), vxil.com/docs/guide/06-feature-catalog (realtime + the `@vxil/realtime` browser client,\nauth), and `examples/ecommerce/` for the same patterns composed with functions.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
17417
17418
  "functions": {}
17418
17419
  },
17419
17420
  {
@@ -17433,8 +17434,8 @@ var TEMPLATE_CATALOG = [
17433
17434
  ],
17434
17435
  "hasFunctions": false,
17435
17436
  "byoKeys": [],
17436
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Community\" \u2014 a forum backend (threads \u2192 replies, one-vote-per-user voting,\n// live updates), declared end-to-end in ONE typed file. A BLUEPRINT composing\n// shipped building blocks \u2014\n// \u2022 cms \u2192 threads \u2192 replies (resolved by relation) + votes\n// \u2022 auth \u2192 accounts, so an author is a verified end-user\n// \u2022 realtime \u2192 live thread updates (wired config-only via the cms CDC bridge)\n// The load-bearing tricks: `ownerField: 'author'` (a member edits only their\n// OWN posts), `$inc` on `replies_count` (atomic counter, no read-modify-write),\n// and the COMPOSED-KEY vote \u2014 `pair` = voter + ':' + thread, derived by a hook\n// and declared `unique: true` (carried by `vxil push`, cms.md \xA79.3), so N\n// racing votes yield exactly one 201. Everything here is DATA the tenant owns\n// and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Posts go live when created with `status: 'published'` (the README\n // curls do); keep drafts as a moderation hold state if you want a\n // review queue. (`vxil seed` sends no status, so seed items land as drafts.)\n draftPublish: true,\n hooks: {\n // A thread needs a real title \u2014 whitespace-only is rejected (Lane-A).\n thread_title: {\n collection: 'threads',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.title)) > 0',\n message: 'a thread needs a title',\n },\n // A reply needs a non-empty body.\n reply_body: {\n collection: 'replies',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.body)) > 0',\n message: 'a reply needs a body',\n },\n // ONE VOTE PER USER, half 1: both inputs must be present\u2026\n vote_inputs: {\n collection: 'votes',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.voter)) > 0 && len(trim(item.thread)) > 0',\n message: 'a vote needs a voter and a thread',\n },\n // \u2026half 2: derive the composed key `pair` = voter + ':' + thread\n // SERVER-SIDE (a client can never mis-compose it). Half 3 is the\n // `unique: true` claim on votes.pair below \u2014 the insert IS the guard.\n vote_pair: {\n collection: 'votes',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'pair',\n expr: \"concat(item.voter, ':', item.thread)\",\n },\n },\n // Realtime CDC bridge: thread + reply writes\n // auto-publish `cms.item.created/updated/published` frames onto the\n // 'threads:live' realtime channel \u2014 config-only live updates. At-most-once\n // (a live-view convenience; guaranteed delivery stays webhooks/functions).\n cdc: {\n threads_live: {\n collection: 'threads',\n channel: 'threads:live',\n events: ['created', 'updated', 'published'],\n payload: 'ids',\n },\n replies_live: {\n collection: 'replies',\n channel: 'threads:live',\n events: ['created', 'published'],\n payload: 'ids',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // members sign in as end-users\n realtime: {}, // defaults are fine \u2014 channels for the CDC frames above\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n threads: {\n singular: 'thread',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified member\n // may only edit their OWN threads. Server callers are unaffected.\n ownerField: 'author',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // Declarative uniqueness (cms.md \xA79.3), carried by `vxil push`: a\n // duplicate slug is a clean 409 unique_violation. (Flipping the flag\n // on an ALREADY-pushed field is a dashboard/REST in-place alter \u2014\n // push's diff compares field name+type only.)\n slug: { type: 'string', indexSlot: 's2', unique: true },\n author: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n category: { type: 'string', indexSlot: 's4' },\n // validation.min: 0 makes `$inc: { replies_count: -1 }` a conditional\n // decrement \u2014 the counter can never go negative under races.\n replies_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_activity: { type: 'datetime', indexSlot: 't1' }, // \"hot threads\" sort key\n body: { type: 'text' },\n },\n },\n replies: {\n singular: 'reply',\n ownerField: 'author',\n fields: {\n // Slot-bound relation = the JOIN declaration: dotted filter keys like\n // {\"thread.category\": \"announcements\"} reach the parent thread\n // (vxil.com/docs/guide/04-data-with-cms).\n thread: { type: 'relation', relationTo: 'threads', indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' },\n body: { type: 'text', required: true },\n posted_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n votes: {\n singular: 'vote',\n fields: {\n thread: { type: 'relation', relationTo: 'threads', indexSlot: 's1' },\n // The voting member's id \u2014 asserted by YOUR backend (server key) in\n // this blueprint's flow. For direct end-user voting, declare\n // `ownerField: 'voter'` so the verified session id is enforced\n // (cms.md \xA715) \u2014 at the cost of end-user keys then listing/counting\n // only their OWN votes.\n voter: { type: 'string', indexSlot: 's2' },\n // THE COMPOSED KEY \u2014 'voter:thread', derived by the vote_pair hook\n // above and declared unique: one vote per member per thread. N\n // racing votes \u2192 exactly one 201, the rest 409 unique_violation \u2014\n // no lock, no read-check-write.\n pair: { type: 'string', indexSlot: 's3', unique: true },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'threads',\n items: [\n {\n title: 'Welcome to the community',\n slug: 'welcome',\n author: 'admin',\n category: 'announcements',\n replies_count: 0,\n last_activity: '2026-01-01T00:00:00Z',\n body: 'Introduce yourself below.',\n },\n ],\n },\n {\n // Seed items are POSTed verbatim (no cross-item ref resolution), so this\n // reply carries no `thread` id \u2014 attach replies at runtime with the real\n // item_id (see the README snippet).\n collection: 'replies',\n items: [\n {\n author: 'admin',\n body: 'Say hi and tell us what you are building.',\n posted_at: '2026-01-01T00:00:00Z',\n },\n ],\n },\n ],\n },\n});\n",
17437
- "readme": '# Forum / Community template\n\nA forum backend \u2014 threads, replies, and votes \u2014 declared end-to-end in one typed `vxil.config.ts`.\nAuthors are verified end-users (`auth`), and thread activity streams live over `realtime`\nvia the config-only cms CDC bridge.\n\n**Provisions:**\n- `threads` \u2014 title (non-empty, hook-enforced), unique slug, `author` (owner-scoped), category,\n `replies_count`, `last_activity`, body.\n- `replies` \u2014 `thread` relation (slot-bound = join-able), `author` (owner-scoped), body, `posted_at`.\n- `votes` \u2014 `thread` relation, voter, and the unique composed key `pair` = `voter + \':\' + thread`.\n- `realtime` \u2014 thread/reply writes auto-publish onto the `threads:live` channel (`cdc` config bag).\n\n**Use it:**\n\n```bash\nvxil init --template community\nvxil quickstart\nvxil push # carries the `unique: true` claims on threads.slug + votes.pair (cms.md \xA79.3)\nvxil seed # the demo seed (1 thread + 1 reply) \u2014 push does not apply it\nvxil gen\n```\n\n**What to learn from this:**\n- **One vote per user, race-safe.** A `derive` hook composes `pair` server-side; the declarative\n `unique: true` on `votes.pair` (in the config, carried by `vxil push` \u2014 cms.md \xA79.3) makes the\n insert itself the guard \u2014 N racing votes yield exactly one 201, the rest `409 unique_violation`.\n No lock, no read-check-write. `voter` is asserted by your backend here; for direct end-user voting,\n declare `ownerField: \'voter\'` so the verified session id is enforced (cms.md \xA715).\n- **Atomic counters with `$inc`.** Bump `replies_count` in one conditional statement\n (`validation.min: 0` means a decrement can never go negative). Note: Lane-A hooks do NOT\n run on `$inc` \u2014 keep hook-guarded invariants off `$inc` fields.\n- **Join-filter reads (dotted keys).** `replies.thread` is a slot-bound relation, so a filter\n can reach the parent: `?filter={"thread.category":"announcements"}` lists replies whose\n thread is in a category \u2014 ops `$eq $ne $gt $gte $lt $lte $in` only (vxil.com/docs/guide/04-data-with-cms).\n- **Live threads.** Subscribe a browser to `threads:live` with `@vxil/realtime` (token from your\n backend via `POST /v1/realtime/tokens`) and render `cms.item.created`/`updated` frames as they land.\n\n```bash\n# reply to a thread, then bump its counter atomically ($inc never mixes with data)\ncurl -X POST "https://api.vxil.com/v1/cms/items/replies" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"data":{"thread":"itm_THREAD","author":"u_42","body":"Hi!","posted_at":"2026-07-11T12:00:00Z"},"status":"published"}\'\ncurl -X PATCH "https://api.vxil.com/v1/cms/items/threads/itm_THREAD" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"$inc":{"replies_count":1}}\'\n# vote \u2014 the pair "u_42:itm_THREAD" is derived server-side; voting twice \u2192 409\ncurl -X POST "https://api.vxil.com/v1/cms/items/votes" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"data":{"thread":"itm_THREAD","voter":"u_42"},"status":"published"}\'\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (join filters \xB7 `ownerField`), vxil.com/docs/guide/07-validation-and-hooks,\nvxil.com/docs/api (`$inc`/`unique` on the item write routes), vxil.com/docs/guide/06-feature-catalog (realtime, auth),\nand `examples/ecommerce/` for the same patterns under a checkout saga.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
17437
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Community\" \u2014 a forum backend (threads \u2192 replies, one-vote-per-user voting,\n// live updates), declared end-to-end in ONE typed file. A BLUEPRINT composing\n// shipped building blocks \u2014\n// \u2022 cms \u2192 threads \u2192 replies (resolved by relation) + votes\n// \u2022 auth \u2192 accounts, so an author is a verified end-user\n// \u2022 realtime \u2192 live thread updates (wired config-only via the cms CDC bridge)\n// The load-bearing tricks: `ownerField: 'author'` (a member edits only their\n// OWN posts), `$inc` on `replies_count` (atomic counter, no read-modify-write),\n// and the COMPOSED-KEY vote \u2014 `pair` = voter + ':' + thread, derived by a hook\n// and declared `unique: true` (carried by `vxil push`, guide ch. 4, uniqueness), so N\n// racing votes yield exactly one 201. Everything here is DATA the tenant owns\n// and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Posts go live when created with `status: 'published'` (the README\n // curls do); keep drafts as a moderation hold state if you want a\n // review queue. (`vxil seed` sends no status, so seed items land as drafts.)\n draftPublish: true,\n hooks: {\n // A thread needs a real title \u2014 whitespace-only is rejected (Lane-A).\n thread_title: {\n collection: 'threads',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.title)) > 0',\n message: 'a thread needs a title',\n },\n // A reply needs a non-empty body.\n reply_body: {\n collection: 'replies',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.body)) > 0',\n message: 'a reply needs a body',\n },\n // ONE VOTE PER USER, half 1: both inputs must be present\u2026\n vote_inputs: {\n collection: 'votes',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.voter)) > 0 && len(trim(item.thread)) > 0',\n message: 'a vote needs a voter and a thread',\n },\n // \u2026half 2: derive the composed key `pair` = voter + ':' + thread\n // SERVER-SIDE (a client can never mis-compose it). Half 3 is the\n // `unique: true` claim on votes.pair below \u2014 the insert IS the guard.\n vote_pair: {\n collection: 'votes',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'pair',\n expr: \"concat(item.voter, ':', item.thread)\",\n },\n },\n // Realtime CDC bridge: thread + reply writes\n // auto-publish `cms.item.created/updated/published` frames onto the\n // 'threads:live' realtime channel \u2014 config-only live updates. At-most-once\n // (a live-view convenience; guaranteed delivery stays webhooks/functions).\n cdc: {\n threads_live: {\n collection: 'threads',\n channel: 'threads:live',\n events: ['created', 'updated', 'published'],\n payload: 'ids',\n },\n replies_live: {\n collection: 'replies',\n channel: 'threads:live',\n events: ['created', 'published'],\n payload: 'ids',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // members sign in as end-users\n realtime: {}, // defaults are fine \u2014 channels for the CDC frames above\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n threads: {\n singular: 'thread',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified member\n // may only edit their OWN threads. Server callers are unaffected.\n ownerField: 'author',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // Declarative uniqueness (guide ch. 4, uniqueness), carried by `vxil push`: a\n // duplicate slug is a clean 409 unique_violation. (Flipping the flag\n // on an ALREADY-pushed field is a dashboard/REST in-place alter \u2014\n // push's diff compares field name+type only.)\n slug: { type: 'string', indexSlot: 's2', unique: true },\n author: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n category: { type: 'string', indexSlot: 's4' },\n // validation.min: 0 makes `$inc: { replies_count: -1 }` a conditional\n // decrement \u2014 the counter can never go negative under races.\n replies_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_activity: { type: 'datetime', indexSlot: 't1' }, // \"hot threads\" sort key\n body: { type: 'text' },\n },\n },\n replies: {\n singular: 'reply',\n ownerField: 'author',\n fields: {\n // Slot-bound relation = the JOIN declaration: dotted filter keys like\n // {\"thread.category\": \"announcements\"} reach the parent thread\n // (vxil.com/docs/guide/04-data-with-cms).\n thread: { type: 'relation', relationTo: 'threads', indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' },\n body: { type: 'text', required: true },\n posted_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n votes: {\n singular: 'vote',\n fields: {\n thread: { type: 'relation', relationTo: 'threads', indexSlot: 's1' },\n // The voting member's id \u2014 asserted by YOUR backend (server key) in\n // this blueprint's flow. For direct end-user voting, declare\n // `ownerField: 'voter'` so the verified session id is enforced\n // (guide ch. 4, ownerField) \u2014 at the cost of end-user keys then listing/counting\n // only their OWN votes.\n voter: { type: 'string', indexSlot: 's2' },\n // THE COMPOSED KEY \u2014 'voter:thread', derived by the vote_pair hook\n // above and declared unique: one vote per member per thread. N\n // racing votes \u2192 exactly one 201, the rest 409 unique_violation \u2014\n // no lock, no read-check-write.\n pair: { type: 'string', indexSlot: 's3', unique: true },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'threads',\n items: [\n {\n title: 'Welcome to the community',\n slug: 'welcome',\n author: 'admin',\n category: 'announcements',\n replies_count: 0,\n last_activity: '2026-01-01T00:00:00Z',\n body: 'Introduce yourself below.',\n },\n ],\n },\n {\n // Seed items are POSTed verbatim (no cross-item ref resolution), so this\n // reply carries no `thread` id \u2014 attach replies at runtime with the real\n // item_id (see the README snippet).\n collection: 'replies',\n items: [\n {\n author: 'admin',\n body: 'Say hi and tell us what you are building.',\n posted_at: '2026-01-01T00:00:00Z',\n },\n ],\n },\n ],\n },\n});\n",
17438
+ "readme": '# Forum / Community template\n\nA forum backend \u2014 threads, replies, and votes \u2014 declared end-to-end in one typed `vxil.config.ts`.\nAuthors are verified end-users (`auth`), and thread activity streams live over `realtime`\nvia the config-only cms CDC bridge.\n\n**Provisions:**\n- `threads` \u2014 title (non-empty, hook-enforced), unique slug, `author` (owner-scoped), category,\n `replies_count`, `last_activity`, body.\n- `replies` \u2014 `thread` relation (slot-bound = join-able), `author` (owner-scoped), body, `posted_at`.\n- `votes` \u2014 `thread` relation, voter, and the unique composed key `pair` = `voter + \':\' + thread`.\n- `realtime` \u2014 thread/reply writes auto-publish onto the `threads:live` channel (`cdc` config bag).\n\n**Use it:**\n\n```bash\nvxil init --template community\nvxil quickstart\nvxil push # carries the `unique: true` claims on threads.slug + votes.pair (guide ch. 4, uniqueness)\nvxil seed # the demo seed (1 thread + 1 reply) \u2014 push does not apply it\nvxil gen\n```\n\n**What to learn from this:**\n- **One vote per user, race-safe.** A `derive` hook composes `pair` server-side; the declarative\n `unique: true` on `votes.pair` (in the config, carried by `vxil push` \u2014 guide ch. 4, uniqueness) makes the\n insert itself the guard \u2014 N racing votes yield exactly one 201, the rest `409 unique_violation`.\n No lock, no read-check-write. `voter` is asserted by your backend here; for direct end-user voting,\n declare `ownerField: \'voter\'` so the verified session id is enforced (guide ch. 4, ownerField).\n- **Atomic counters with `$inc`.** Bump `replies_count` in one conditional statement\n (`validation.min: 0` means a decrement can never go negative). Note: Lane-A hooks do NOT\n run on `$inc` \u2014 keep hook-guarded invariants off `$inc` fields.\n- **Join-filter reads (dotted keys).** `replies.thread` is a slot-bound relation, so a filter\n can reach the parent: `?filter={"thread.category":"announcements"}` lists replies whose\n thread is in a category \u2014 ops `$eq $ne $gt $gte $lt $lte $in` only (vxil.com/docs/guide/04-data-with-cms).\n- **Live threads.** Subscribe a browser to `threads:live` with `@vxil/realtime` (token from your\n backend via `POST /v1/realtime/tokens`) and render `cms.item.created`/`updated` frames as they land.\n\n```bash\n# reply to a thread, then bump its counter atomically ($inc never mixes with data)\ncurl -X POST "https://api.vxil.com/v1/cms/items/replies" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"data":{"thread":"itm_THREAD","author":"u_42","body":"Hi!","posted_at":"2026-07-11T12:00:00Z"},"status":"published"}\'\ncurl -X PATCH "https://api.vxil.com/v1/cms/items/threads/itm_THREAD" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"$inc":{"replies_count":1}}\'\n# vote \u2014 the pair "u_42:itm_THREAD" is derived server-side; voting twice \u2192 409\ncurl -X POST "https://api.vxil.com/v1/cms/items/votes" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"data":{"thread":"itm_THREAD","voter":"u_42"},"status":"published"}\'\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (join filters \xB7 `ownerField`), vxil.com/docs/guide/07-validation-and-hooks,\nvxil.com/docs/api (`$inc`/`unique` on the item write routes), vxil.com/docs/guide/06-feature-catalog (realtime, auth),\nand `examples/ecommerce/` for the same patterns under a checkout saga.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
17438
17439
  "functions": {}
17439
17440
  },
17440
17441
  {
@@ -17497,12 +17498,12 @@ export default defineConfig({
17497
17498
  features: {
17498
17499
  cms: {
17499
17500
  // The editorial lifecycle: write as a draft, publish live. Only PUBLISHED
17500
- // rows are served on the keyless public lane (\xA716) \u2014 a draft is private
17501
+ // rows are served on the keyless public lane (guide ch. 4, public delivery) \u2014 a draft is private
17501
17502
  // until you publish it, so you can stage a whole release behind an API key.
17502
17503
  draftPublish: true,
17503
17504
  hooks: {
17504
17505
  // Every page needs a title AND a url-safe slug \u2014 pure functions of the row
17505
- // (Lane-A validate, AST-checked at \`vxil push\`, run in the write tx, \xA77).
17506
+ // (Lane-A validate, AST-checked at \`vxil push\`, run in the write tx, guide ch. 7).
17506
17507
  page_title: {
17507
17508
  collection: 'pages',
17508
17509
  event: 'beforeWrite',
@@ -17536,7 +17537,7 @@ export default defineConfig({
17536
17537
  // sidebar renders from the keyless lane with no API key.
17537
17538
  sections: {
17538
17539
  singular: 'section',
17539
- // PUBLIC DELIVERY (cms.md \xA716): keyless, edge-cached reads of published sections.
17540
+ // PUBLIC DELIVERY (guide ch. 4, public delivery): keyless, edge-cached reads of published sections.
17540
17541
  public: true,
17541
17542
  fields: {
17542
17543
  title: { type: 'string', required: true, indexSlot: 's1' },
@@ -17548,11 +17549,11 @@ export default defineConfig({
17548
17549
  // The documentation pages themselves \u2014 the whole reader surface.
17549
17550
  pages: {
17550
17551
  singular: 'page',
17551
- // PUBLIC DELIVERY (cms.md \xA716): the reader tier \u2014 published pages read with
17552
+ // PUBLIC DELIVERY (guide ch. 4, public delivery): the reader tier \u2014 published pages read with
17552
17553
  // NO API key over GET /v1/cms/public/:tenantId/pages, edge-cached
17553
17554
  // (s-maxage=60, stale-while-revalidate). Drafts are NEVER served, so an
17554
17555
  // unpublished page stays private. A page can be reached one hop by its
17555
- // section: ?filter={"section.slug":"guides"} (\xA712.1 single-hop join).
17556
+ // section: ?filter={"section.slug":"guides"} (single-hop join, guide ch. 4).
17556
17557
  public: true,
17557
17558
  fields: {
17558
17559
  title: { type: 'string', required: true, indexSlot: 's1' },
@@ -17567,7 +17568,7 @@ export default defineConfig({
17567
17568
  // A release feed \u2014 the changelog. PUBLIC so a "/changelog" page reads keyless.
17568
17569
  changelog: {
17569
17570
  singular: 'release',
17570
- // PUBLIC DELIVERY (cms.md \xA716): the public release feed \u2014 keyless,
17571
+ // PUBLIC DELIVERY (guide ch. 4, public delivery): the public release feed \u2014 keyless,
17571
17572
  // ?sort=-released_at for newest-first, edge-cached.
17572
17573
  public: true,
17573
17574
  fields: {
@@ -17605,7 +17606,7 @@ export default defineConfig({
17605
17606
  },
17606
17607
  });
17607
17608
  `,
17608
- "readme": "# Docs Site / Changelog template\n\nA **public content site** \u2014 documentation sections and pages plus a changelog feed \u2014 declared end-to-end\nin one typed `vxil.config.ts`. This is the canonical showcase of **cms public delivery**\n(vxil.com/docs/guide/04-data-with-cms): every reader-facing collection is `public: true`, so a static/JAMstack\nfront-end serves the whole site over the **keyless, edge-cached** `GET /v1/cms/public/:tenantId/:collection`\nlane \u2014 **no API key on the read path at all**. Authors write drafts behind an API key; readers see only\n**published** rows, served from the edge cache.\n\n**What it provisions (all `cms` collections you own and can edit):**\n- `sections` \u2014 the doc nav tree (title, unique `slug`, `order` for the sidebar, summary). **Public.**\n- `pages` \u2014 the documentation pages (title, unique `slug`, a `section` relation slot-bound for the\n single-hop join, `order`, markdown `body`, `updated_at`, JSON `tags`). **Public.** A Lane-A hook\n requires a title and a lowercase slug.\n- `changelog` \u2014 a release feed (`version`, `title`, `kind`, `released_at`, markdown `body`). **Public.**\n A Lane-A hook requires a version.\n- Feature: `cms` with `draftPublish` \u2014 write as draft, publish live.\n\n**Apply it:**\n\n```bash\nvxil init --template docs-site\nvxil quickstart # a fresh backend (or `vxil link <slug>` for an existing one)\nvxil push # apply the collections + hooks (the `public: true` flags ride push \u2014 cms.md \xA716)\nvxil gen # typed SDK + per-tenant MCP catalog\n```\n\n## The whole point: a keyless public read path\n\nBecause `sections`, `pages`, and `changelog` are `public: true`, your front-end reads them with **no API\nkey** \u2014 the edge mints a restricted read-only token bound to your tenant, forces `status = 'published'`,\nand edge-caches the response (`s-maxage=60`, `stale-while-revalidate`). Drafts are never served.\n\n```ts\n// Reader front-end \u2014 NO api key, no Vxil client, no auth. The served SDK's\n// keyless helper (vxil.com/docs/guide/04-data-with-cms); or hit the URL with plain fetch.\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n\nconst TENANT = 'ten_your_tenant_id';\n\n// the sidebar: published sections, in order\nconst { items: sections } = await listCmsPublic(TENANT, 'sections', { sort: 'order', limit: 100 });\n\n// one page by slug (published only \u2014 a draft slug 404s to the reader)\nconst { items: [page] } = await listCmsPublic(TENANT, 'pages', { filter: { slug: 'introduction' }, limit: 1 });\n\n// every page in the \"guides\" section \u2014 the \xA712.1 single-hop dotted-key join\nconst guides = await listCmsPublic(TENANT, 'pages', { filter: { 'section.slug': 'guides' }, sort: 'order' });\n\n// the changelog, newest first (released_at is slot-bound \u21D2 index-served)\nconst { items: releases } = await listCmsPublic(TENANT, 'changelog', { sort: '-released_at', limit: 25 });\n```\n\nOr with plain `fetch` (any language, any runtime):\n\n```bash\ncurl \"https://api.vxil.com/v1/cms/public/$TENANT/pages?sort=order&limit=100\"\n```\n\n## What to learn from this\n\n1. **Public delivery is one collection flag** (vxil.com/docs/guide/04-data-with-cms) \u2014 `public: true` opts a\n collection into the keyless lane; it is layered **on top of** the tenant isolation boundary, never replacing it.\n Carried by **both** push paths (the `vxil push` reconciler AND control-plane `/v1/apply`), so the flag\n is not read-only inert.\n2. **Published-only, default-deny** \u2014 the lane FORCES `status = 'published'`; a `$status` filter is\n rejected, so no query param can ever surface a draft. Keep a page draft while writing and it stays\n private until you publish it \u2014 you can stage a whole release behind an API key, then publish atomically.\n3. **Owner ids never leak** \u2014 this lane is owner-**unscoped** by design (public content is not per-user);\n if a public collection also declares an `ownerField`, it is stripped from every served row. (The doc\n collections here declare none \u2014 they are shared content.)\n4. **Editorial invariants ride as tenant-owned Lane-A hooks** \u2014 \"a page needs a title\", \"slug must be\n lowercase\", \"a release needs a version\" are AST-checked safe expressions in **your** config, run inside\n the write transaction (\xA77), not platform code.\n5. **Honest limits** (\xA716) \u2014 the public lane is **read-only** (no keyless writes), serves the safe query\n subset (`filter`/`sort`/`limit`/`cursor`) only \u2014 not the \xA712 relational read-models \u2014 and never more\n than **100 rows/page**. Freshness is eventually-consistent within `s-maxage=60` of a change.\n\n## Add reader comments (optional)\n\nTo let readers comment on a page, enable the `comments` feature and drop the\n[`@vxil/react/comments`](../../packages/react) widget onto your page (a pure client-side component\nover the shipped comments API). The public docs stay keyless; comments authenticate as end-users.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\n`templates/blog/` (an editorial variant with reader comments) \xB7 `templates/catalog/` (a public product grid).\n\n**Own the shape.** The config is yours after `init` \u2014 add a `docs`-vs-`api` section type, an `authors`\nrelation, a search-index collection. Nothing is locked.\n",
17609
+ "readme": "# Docs Site / Changelog template\n\nA **public content site** \u2014 documentation sections and pages plus a changelog feed \u2014 declared end-to-end\nin one typed `vxil.config.ts`. This is the canonical showcase of **cms public delivery**\n(vxil.com/docs/guide/04-data-with-cms): every reader-facing collection is `public: true`, so a static/JAMstack\nfront-end serves the whole site over the **keyless, edge-cached** `GET /v1/cms/public/:tenantId/:collection`\nlane \u2014 **no API key on the read path at all**. Authors write drafts behind an API key; readers see only\n**published** rows, served from the edge cache.\n\n**What it provisions (all `cms` collections you own and can edit):**\n- `sections` \u2014 the doc nav tree (title, unique `slug`, `order` for the sidebar, summary). **Public.**\n- `pages` \u2014 the documentation pages (title, unique `slug`, a `section` relation slot-bound for the\n single-hop join, `order`, markdown `body`, `updated_at`, JSON `tags`). **Public.** A Lane-A hook\n requires a title and a lowercase slug.\n- `changelog` \u2014 a release feed (`version`, `title`, `kind`, `released_at`, markdown `body`). **Public.**\n A Lane-A hook requires a version.\n- Feature: `cms` with `draftPublish` \u2014 write as draft, publish live.\n\n**Apply it:**\n\n```bash\nvxil init --template docs-site\nvxil quickstart # a fresh backend (or `vxil link <slug>` for an existing one)\nvxil push # apply the collections + hooks (the `public: true` flags ride push \u2014 guide ch. 4, public delivery)\nvxil gen # typed SDK + per-tenant MCP catalog\n```\n\n## The whole point: a keyless public read path\n\nBecause `sections`, `pages`, and `changelog` are `public: true`, your front-end reads them with **no API\nkey** \u2014 the edge mints a restricted read-only token bound to your tenant, forces `status = 'published'`,\nand edge-caches the response (`s-maxage=60`, `stale-while-revalidate`). Drafts are never served.\n\n```ts\n// Reader front-end \u2014 NO api key, no Vxil client, no auth. The served SDK's\n// keyless helper (vxil.com/docs/guide/04-data-with-cms); or hit the URL with plain fetch.\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n\nconst TENANT = 'ten_your_tenant_id';\n\n// the sidebar: published sections, in order\nconst { items: sections } = await listCmsPublic(TENANT, 'sections', { sort: 'order', limit: 100 });\n\n// one page by slug (published only \u2014 a draft slug 404s to the reader)\nconst { items: [page] } = await listCmsPublic(TENANT, 'pages', { filter: { slug: 'introduction' }, limit: 1 });\n\n// every page in the \"guides\" section \u2014 the single-hop dotted-key join (guide ch. 4, relational depth)\nconst guides = await listCmsPublic(TENANT, 'pages', { filter: { 'section.slug': 'guides' }, sort: 'order' });\n\n// the changelog, newest first (released_at is slot-bound \u21D2 index-served)\nconst { items: releases } = await listCmsPublic(TENANT, 'changelog', { sort: '-released_at', limit: 25 });\n```\n\nOr with plain `fetch` (any language, any runtime):\n\n```bash\ncurl \"https://api.vxil.com/v1/cms/public/$TENANT/pages?sort=order&limit=100\"\n```\n\n## What to learn from this\n\n1. **Public delivery is one collection flag** (vxil.com/docs/guide/04-data-with-cms) \u2014 `public: true` opts a\n collection into the keyless lane; it is layered **on top of** the tenant isolation boundary, never replacing it.\n Carried by **both** push paths (the `vxil push` reconciler AND control-plane `/v1/apply`), so the flag\n is not read-only inert.\n2. **Published-only, default-deny** \u2014 the lane FORCES `status = 'published'`; a `$status` filter is\n rejected, so no query param can ever surface a draft. Keep a page draft while writing and it stays\n private until you publish it \u2014 you can stage a whole release behind an API key, then publish atomically.\n3. **Owner ids never leak** \u2014 this lane is owner-**unscoped** by design (public content is not per-user);\n if a public collection also declares an `ownerField`, it is stripped from every served row. (The doc\n collections here declare none \u2014 they are shared content.)\n4. **Editorial invariants ride as tenant-owned Lane-A hooks** \u2014 \"a page needs a title\", \"slug must be\n lowercase\", \"a release needs a version\" are AST-checked safe expressions in **your** config, run inside\n the write transaction (guide ch. 7), not platform code.\n5. **Honest limits** (guide ch. 4, public delivery) \u2014 the public lane is **read-only** (no keyless writes), serves the safe query\n subset (`filter`/`sort`/`limit`/`cursor`) only \u2014 not the relational read-models \u2014 and never more\n than **100 rows/page**. Freshness is eventually-consistent within `s-maxage=60` of a change.\n\n## Add reader comments (optional)\n\nTo let readers comment on a page, enable the `comments` feature and drop the\n[`@vxil/react/comments`](../../packages/react) widget onto your page (a pure client-side component\nover the shipped comments API). The public docs stay keyless; comments authenticate as end-users.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\n`templates/blog/` (an editorial variant with reader comments) \xB7 `templates/catalog/` (a public product grid).\n\n**Own the shape.** The config is yours after `init` \u2014 add a `docs`-vs-`api` section type, an `authors`\nrelation, a search-index collection. Nothing is locked.\n",
17609
17610
  "functions": {}
17610
17611
  },
17611
17612
  {
@@ -17628,12 +17629,12 @@ export default defineConfig({
17628
17629
  "byoKeys": [
17629
17630
  "openai_key"
17630
17631
  ],
17631
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"AI Journal\" \u2014 an AI-powered private journal, declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 owner-scoped `entries` (each journal belongs to its writer)\n// \u2022 ai \u2192 the enrichment calls (summary + mood) \u2014 BYO provider key\n// \u2022 rag \u2192 \"ask your journal\": retrieval-grounded answers with citations\n// \u2022 vector-search \u2192 rag's retrieval leg (mock embedder by default, zero-config)\n// \u2022 functions \u2192 the async glue: enrich-on-write, ask endpoint, weekly cron\n// \u2022 notifications \u2192 the weekly digest email (mock provider until you wire one)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n//\n// ONE-TIME SETUP after `vxil push`: create the retrieval index (a vector-search\n// collection) once \u2014\n// curl -X POST https://api.vxil.com/v1/search/collections \\\n// -H \"Authorization: Bearer $VXIL_KEY\" -H \"Content-Type: application/json\" \\\n// -d '{\"collection\":\"journal\"}'\n// (dimensions/embedder come from the vector-search config defaults below).\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // Every entry needs a title \u2014 a pure function of the row (Lane-A validate).\n entry_title: {\n collection: 'entries',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.title) > 0',\n message: 'an entry needs a title',\n },\n // Stamp written_at when the client omits it (Lane-A derive; `now` \u2014 one\n // ISO per request \u2014 is the write path's only ambient input).\n entry_written_at: {\n collection: 'entries',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'written_at',\n expr: 'coalesce(item.written_at, now)',\n },\n },\n },\n\n // The AI enrichment runs keyless out of the box: 'mock' is the deterministic\n // default provider. Go real by (1) `vxil secrets set ai/openai_key`, then\n // (2) flipping defaultProvider to 'openai' and defaults.model to a real one\n // (e.g. 'gpt-4.1-mini'). The keyRef below already points at the secret.\n ai: {\n defaultProvider: 'mock',\n providers: { openaiKeyRef: 'openai_key' }, // \u2192 secrets.openai_key (envelope-encrypted)\n defaults: { model: 'mock-1', maxTokens: 512, temperature: 0.4 },\n },\n\n // rag owns the retrieve\u2192ground\u2192generate\u2192cite pipeline; the prompt/synthesis\n // stay yours. `defaultCollection` lets callers omit `collection`.\n rag: {\n defaultCollection: 'journal',\n retrieval: { topK: 6 },\n },\n\n // rag's retrieval leg \u2014 must be enabled or /v1/rag/* answers 501. The 'mock'\n // embedder is the zero-config default; for real embeddings set\n // embed: { provider: 'openai', model: 'text-embedding-3-small', apiKeyRef: \u2026 }.\n 'vector-search': {},\n\n notifications: { provider: 'mock', fromEmail: 'digest@journal.app' },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n entries: {\n singular: 'entry',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified end-user\n // reads/writes only their OWN entries. Server callers are unaffected.\n ownerField: 'user_id',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n body: { type: 'text' },\n mood: { type: 'string', indexSlot: 's2' }, // AI-derived by on-entry-written\n summary: { type: 'text' }, // AI-derived by on-entry-written\n written_at: { type: 'datetime', indexSlot: 't1' },\n user_id: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n tags: { type: 'json' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions (async, cross-feature) \u2500\u2500\n functions: {\n // Enrich on write: re-fetch the entry, ai-generate a 1-sentence summary +\n // one-word mood, PATCH them back, ingest title+body into the rag index.\n 'on-entry-written': {\n entry: './functions/on-entry-written.ts',\n trigger: { kind: 'cmsHook', collection: 'entries', event: 'beforeWrite' },\n scopes: ['cms:read', 'cms:write', 'ai:write', 'rag:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // \"Ask your journal\": POST /v1/fn/ask-journal { question } \u2192 a grounded\n // answer with citations via POST /v1/rag/answer.\n 'ask-journal': {\n entry: './functions/ask-journal.ts',\n trigger: { kind: 'http' },\n scopes: ['rag:write'],\n },\n // Weekly digest: every Monday 08:00 UTC, list the week's entries and send\n // each writer a notifications digest.\n 'weekly-digest': {\n entry: './functions/weekly-digest.ts',\n trigger: { kind: 'cron', schedule: '0 8 * * 1' },\n scopes: ['cms:read', 'notifications:send'],\n },\n },\n\n // \u2500\u2500 SECRET REFERENCES (never values) \u2014 `vxil secrets set ai/openai_key` \u2500\u2500\n secrets: {\n openai_key: { feature: 'ai', description: 'OpenAI API key (BYO provider, envelope-encrypted)' },\n },\n\n seed: {\n cms: [\n {\n collection: 'entries',\n items: [\n {\n title: 'Welcome to your AI journal',\n body: 'Write anything. On every save, a function summarizes the entry, names its mood, and indexes it \u2014 then you can literally ask your journal questions.',\n written_at: '2026-07-01T09:00:00Z',\n user_id: 'demo-user',\n tags: ['welcome'],\n },\n ],\n },\n ],\n },\n});\n",
17632
- "readme": '# AI Journal template\n\nAn AI-powered private journal \u2014 declared end-to-end in one typed `vxil.config.ts`. Every saved entry is\nenriched by a function (one-sentence summary + one-word mood via the `ai` feature) and indexed for retrieval,\nso you can literally *ask your journal* and get grounded, cited answers back.\n\n**What it provisions:**\n- `entries` \u2014 title, body, AI-derived `mood`/`summary`, `written_at`, tags, and `user_id` as the **owner field**\n (a verified end-user only sees their own journal). Lane-A hooks require a title and stamp `written_at`.\n- Features: `cms` + `ai` + `rag` + `vector-search` (rag\'s retrieval leg) + `notifications` + `functions`.\n- Functions: `on-entry-written` (cmsHook: enrich + ingest), `ask-journal` (http: grounded Q&A),\n `weekly-digest` (cron: Monday digest per writer).\n\n**Apply it:**\n\n```bash\nvxil init --template ai-journal\nvxil quickstart --invite <code> # only when the email is new (or `vxil link` an existing tenant)\nvxil push\nvxil gen\n# one-time: create the retrieval index (dimensions/embedder come from config defaults)\ncurl -X POST https://api.vxil.com/v1/search/collections \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"collection":"journal"}\'\n```\n\n**What to learn from this:**\n1. **AI enrichment on write** \u2014 a `cmsHook` function re-fetches the entry by `item_id` (never trusts inline\n fields), calls `POST /v1/ai/generate` (raw-prompt mode), PATCHes `summary`/`mood` back, and latches on\n `summary` so its own write-back never re-enriches.\n2. **Retrieval-augmented "ask your journal"** \u2014 `POST /v1/rag/answer` retrieves top-k from the `journal`\n index and returns the answer *with citations* (`doc_id` = the entry\'s `item_id`); the prompt stays yours.\n3. **BYO AI key via encrypted secrets** \u2014 config carries only the reference (`providers.openaiKeyRef`);\n `vxil secrets set ai/openai_key` stores the value envelope-encrypted, then flip `defaultProvider`/`model`.\n Until then the deterministic `mock` provider (and mock embedder) run the whole loop keyless.\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ask-journal \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"question":"what made me happy this month?"}\'\n```\n\n**Go deeper:** vxil.com/docs/guide/06-feature-catalog (ai, rag) \xB7 vxil.com/docs/guide/08-running-your-code-functions \xB7\nvxil.com/docs/guide/07-validation-and-hooks \xB7 vxil.com/docs/guide/04-data-with-cms (owner-scope) \xB7 `examples/ecommerce/` (a bigger functions saga).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
17632
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"AI Journal\" \u2014 an AI-powered private journal, declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 owner-scoped `entries` (each journal belongs to its writer)\n// \u2022 ai \u2192 the enrichment calls (summary + mood) \u2014 BYO provider key\n// \u2022 rag \u2192 \"ask your journal\": retrieval-grounded answers with citations\n// \u2022 vector-search \u2192 rag's retrieval leg (mock embedder by default, zero-config)\n// \u2022 functions \u2192 the async glue: enrich-on-write, ask endpoint, weekly cron\n// \u2022 notifications \u2192 the weekly digest email (mock provider until you wire one)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n//\n// ONE-TIME SETUP after `vxil push`: create the retrieval index (a vector-search\n// collection) once \u2014\n// curl -X POST https://api.vxil.com/v1/search/collections \\\n// -H \"Authorization: Bearer $VXIL_KEY\" -H \"Content-Type: application/json\" \\\n// -d '{\"collection\":\"journal\"}'\n// (dimensions/embedder come from the vector-search config defaults below).\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Fail-safe owner scoping: an end-user session may only touch collections\n // with an `ownerField` (`entries` has one); anything added later without\n // one answers `403 server_only` instead of being shared between users.\n strictEndUserScope: true,\n hooks: {\n // Every entry needs a title \u2014 a pure function of the row (Lane-A validate).\n entry_title: {\n collection: 'entries',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.title) > 0',\n message: 'an entry needs a title',\n },\n // Stamp written_at when the client omits it (Lane-A derive; `now` \u2014 one\n // ISO per request \u2014 is the write path's only ambient input).\n entry_written_at: {\n collection: 'entries',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'written_at',\n expr: 'coalesce(item.written_at, now)',\n },\n },\n },\n\n // The AI enrichment runs keyless out of the box: 'mock' is the deterministic\n // default provider. Go real by (1) `vxil secrets set ai/openai_key`, then\n // (2) flipping defaultProvider to 'openai' and defaults.model to a real one\n // (e.g. 'gpt-4.1-mini'). The keyRef below already points at the secret.\n ai: {\n defaultProvider: 'mock',\n providers: { openaiKeyRef: 'openai_key' }, // \u2192 secrets.openai_key (encrypted at rest)\n defaults: { model: 'mock-1', maxTokens: 512, temperature: 0.4 },\n },\n\n // rag owns the retrieve\u2192ground\u2192generate\u2192cite pipeline; the prompt/synthesis\n // stay yours. `defaultCollection` lets callers omit `collection`.\n rag: {\n defaultCollection: 'journal',\n retrieval: { topK: 6 },\n },\n\n // rag's retrieval leg \u2014 must be enabled or /v1/rag/* answers 501. The 'mock'\n // embedder is the zero-config default; for real embeddings set\n // embed: { provider: 'openai', model: 'text-embedding-3-small', apiKeyRef: \u2026 }.\n 'vector-search': {},\n\n notifications: { provider: 'mock', fromEmail: 'digest@journal.app' },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n entries: {\n singular: 'entry',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified end-user\n // reads/writes only their OWN entries. Server callers are unaffected.\n ownerField: 'user_id',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n body: { type: 'text' },\n mood: { type: 'string', indexSlot: 's2' }, // AI-derived by on-entry-written\n summary: { type: 'text' }, // AI-derived by on-entry-written\n written_at: { type: 'datetime', indexSlot: 't1' },\n user_id: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n tags: { type: 'json' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions (async, cross-feature) \u2500\u2500\n functions: {\n // Enrich on write: re-fetch the entry, ai-generate a 1-sentence summary +\n // one-word mood, PATCH them back, ingest title+body into the rag index.\n 'on-entry-written': {\n entry: './functions/on-entry-written.ts',\n trigger: { kind: 'cmsHook', collection: 'entries', event: 'beforeWrite' },\n scopes: ['cms:read', 'cms:write', 'ai:write', 'rag:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // \"Ask your journal\": POST /v1/fn/ask-journal { question } \u2192 a grounded\n // answer with citations via POST /v1/rag/answer.\n 'ask-journal': {\n entry: './functions/ask-journal.ts',\n trigger: { kind: 'http' },\n scopes: ['rag:write'],\n },\n // Weekly digest: every Monday 08:00 UTC, list the week's entries and send\n // each writer a notifications digest.\n 'weekly-digest': {\n entry: './functions/weekly-digest.ts',\n trigger: { kind: 'cron', schedule: '0 8 * * 1' },\n scopes: ['cms:read', 'notifications:send'],\n },\n },\n\n // \u2500\u2500 SECRET REFERENCES (never values) \u2014 `vxil secrets set ai/openai_key` \u2500\u2500\n secrets: {\n openai_key: { feature: 'ai', description: 'OpenAI API key (BYO provider, encrypted at rest)' },\n },\n\n seed: {\n cms: [\n {\n collection: 'entries',\n items: [\n {\n title: 'Welcome to your AI journal',\n body: 'Write anything. On every save, a function summarizes the entry, names its mood, and indexes it \u2014 then you can literally ask your journal questions.',\n written_at: '2026-07-01T09:00:00Z',\n user_id: 'demo-user',\n tags: ['welcome'],\n },\n ],\n },\n ],\n },\n});\n",
17633
+ "readme": '# AI Journal template\n\nAn AI-powered private journal \u2014 declared end-to-end in one typed `vxil.config.ts`. Every saved entry is\nenriched by a function (one-sentence summary + one-word mood via the `ai` feature) and indexed for retrieval,\nso you can literally *ask your journal* and get grounded, cited answers back.\n\n**What it provisions:**\n- `entries` \u2014 title, body, AI-derived `mood`/`summary`, `written_at`, tags, and `user_id` as the **owner field**\n (a verified end-user only sees their own journal). Lane-A hooks require a title and stamp `written_at`.\n `cms.strictEndUserScope: true` makes any collection you add later without an owner field server-only for\n end users (`403 server_only`) instead of shared between them.\n- Features: `cms` + `ai` + `rag` + `vector-search` (rag\'s retrieval leg) + `notifications` + `functions`.\n- Functions: `on-entry-written` (cmsHook: enrich + ingest), `ask-journal` (http: grounded Q&A),\n `weekly-digest` (cron: Monday digest per writer).\n\n**Apply it:**\n\n```bash\nvxil init --template ai-journal\nvxil quickstart --env staging --no-push --invite <code> # --invite only when the email is new (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push\nvxil gen\n# one-time: create the retrieval index (dimensions/embedder come from config defaults)\ncurl -X POST https://api.vxil.com/v1/search/collections \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"collection":"journal"}\'\n```\n\n> **Plan note.** The three functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n1. **AI enrichment on write** \u2014 a `cmsHook` function re-fetches the entry by `item_id` (never trusts inline\n fields), calls `POST /v1/ai/generate` (raw-prompt mode), PATCHes `summary`/`mood` back, and latches on\n `summary` so its own write-back never re-enriches.\n2. **Retrieval-augmented "ask your journal"** \u2014 `POST /v1/rag/answer` retrieves top-k from the `journal`\n index and returns the answer *with citations* (`doc_id` = the entry\'s `item_id`); the prompt stays yours.\n3. **BYO AI key via encrypted secrets** \u2014 config carries only the reference (`providers.openaiKeyRef`);\n `vxil secrets set ai/openai_key` stores the value encrypted at rest, then flip `defaultProvider`/`model`.\n Until then the deterministic `mock` provider (and mock embedder) run the whole loop keyless.\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ask-journal \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"question":"what made me happy this month?"}\'\n```\n\n**Go deeper:** vxil.com/docs/guide/06-feature-catalog (ai, rag) \xB7 vxil.com/docs/guide/08-running-your-code-functions \xB7\nvxil.com/docs/guide/07-validation-and-hooks \xB7 vxil.com/docs/guide/04-data-with-cms (owner-scope) \xB7 `examples/ecommerce/` (a bigger functions saga).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
17633
17634
  "functions": {
17634
- "ask-journal.ts": "// ask-journal.ts \u2014 \"ASK YOUR JOURNAL\" (a vxil function, \xA77.3).\n//\n// Trigger: http \u2014 POST /v1/fn/ask-journal { question, user_id? }. Runs ONE\n// retrieval-augmented call: POST /v1/rag/answer over the `journal` index the\n// on-entry-written function keeps fed. rag retrieves top-k chunks from\n// vector-search, grounds the tenant-owned prompt, generates via the ai feature,\n// and returns the answer WITH citations pointing at the exact entries used \u2014\n// this function is a thin, scoped wrapper (rag:write only).\n//\n// In end-user mode the verified principal is propagated automatically into the\n// scoped token, so retrieval is owner-scoped; in server mode an optional\n// `user_id` rides along for per-user metering.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ question?: string; user_id?: string }>;\ninterface Citation { chunk_id?: string; doc_id?: string; score?: number }\ninterface AnswerRes { data?: { answer?: string; citations?: Citation[]; usage?: Record<string, unknown> } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const rag = env.scoped_jwts?.rag;\n if (!rag) return json({ error: 'missing rag scope' }, 403);\n\n const question = String(env.payload?.question ?? '').trim();\n if (!question) return json({ error: 'question required', example: { question: 'what made me happy last month?' } }, 400);\n\n const res = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n query: question.slice(0, 2000),\n collection: 'journal', // = rag config defaultCollection; explicit for clarity\n ...(env.payload?.user_id ? { user_id: env.payload.user_id } : {}),\n }),\n });\n if (!res.ok) {\n // A missing index is NOT a 404 here: rag's retrieve leg wraps a\n // vector-search failure as 502 retrieval_failed and attaches the\n // downstream error under error.upstream (only a 501 passes through),\n // so detect collection_not_found in the BODY, not the status. The\n // index is a one-time setup (see the template README).\n const errBody = (await res.json().catch(() => null)) as\n { error?: { code?: string; upstream?: { code?: string } } } | null;\n const code = errBody?.error?.upstream?.code ?? errBody?.error?.code;\n if (res.status === 404 || code === 'collection_not_found') {\n return json({ error: 'journal index not found', hint: 'POST /v1/search/collections {\"collection\":\"journal\"} once, then write an entry' }, 404);\n }\n return json({ error: 'answer_failed', status: res.status }, 502);\n }\n\n const body = (await res.json()) as AnswerRes;\n return json({\n answer: body.data?.answer ?? '',\n // provenance: which entries grounded the answer (doc_id = the entry's item_id)\n sources: (body.data?.citations ?? []).map((c) => ({ entry_id: c.doc_id, score: c.score })),\n }, 200);\n },\n};\n\n// \u2500\u2500 tiny helper \u2500\u2500\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
17635
- "on-entry-written.ts": "// on-entry-written.ts \u2014 AI ENRICHMENT ON WRITE (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `entries`. The hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// entry by id (through the edge, tenant-scoped), then:\n// 1. asks the ai feature (POST /v1/ai/generate, raw-prompt mode) for a\n// ONE-sentence summary and a ONE-word mood,\n// 2. PATCHes them back onto the entry (merge-patch; the summary-present LATCH\n// keeps our own write-back from re-enriching \u2014 clear `summary` to redo),\n// 3. ingests title+body into the rag retrieval index (POST /v1/rag/ingest/\n// journal \u2014 the vector-search passthrough) so ask-journal can ground on it.\n// At-least-once delivery is safe to redeliver: the summary latch skips a\n// re-enrich, the PATCH is idempotent by content, and the ingest converges \u2014\n// vector-search upserts by doc_id, so re-ingesting the same entry re-indexes\n// in place rather than duplicating.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface EntryData { title?: string; body?: string; summary?: string; mood?: string; written_at?: string; user_id?: string }\ninterface Item { data?: { data?: EntryData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const rag = env.scoped_jwts?.rag;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'entries' || !cms || !ai || !rag || !itemId) {\n return Response.json({ skipped: true });\n }\n\n // Re-fetch the entry (the payload carries only the id \u2014 never trust inline fields).\n const res = await fetch(`${base}/v1/cms/items/entries/${itemId}`, { headers: H(cms) });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const entry = ((await res.json()) as Item).data?.data ?? {};\n if (entry.summary) return Response.json({ skipped: true, reason: 'already enriched' });\n if (!entry.body) return Response.json({ skipped: true, reason: 'no body yet' });\n\n // 1. AI enrichment \u2014 two small raw-prompt generations ({ data: { text } }).\n const text = entry.body.slice(0, 6000);\n const summary = clip(await generate(base, ai,\n `Summarize this journal entry in exactly one sentence, first person:\\n\\n${text}`, 80, entry.user_id), 400);\n const moodRaw = await generate(base, ai,\n `Answer with ONE lowercase word (e.g. joyful, anxious, calm, tired) naming the dominant mood of this journal entry:\\n\\n${text}`, 8, entry.user_id);\n const mood = (moodRaw.trim().split(/\\s+/)[0] ?? '').toLowerCase().replace(/[^a-z-]/g, '').slice(0, 24);\n if (!summary) return Response.json({ skipped: true, reason: 'ai unavailable' });\n\n // 2. PATCH the derived fields back (merge-patch keys; bumps `version`).\n const patch = await fetch(`${base}/v1/cms/items/entries/${itemId}`, {\n method: 'PATCH',\n headers: H(cms),\n body: JSON.stringify({ data: { summary, ...(mood ? { mood } : {}) } }),\n });\n\n // 3. Ingest into the retrieval index (rag \u2192 vector-search passthrough, 202).\n // Idempotent by doc_id: vector-search UPSERTs on (collection, doc_id), so a\n // redelivered hook (or an edited entry) re-indexes in place.\n const ing = await fetch(`${base}/v1/rag/ingest/journal`, {\n method: 'POST',\n headers: H(rag),\n body: JSON.stringify({\n doc_id: itemId,\n ...(entry.user_id ? { user_id: entry.user_id } : {}),\n text: `${entry.title ?? ''}\\n\\n${entry.body}`,\n metadata: { ...(mood ? { mood } : {}), ...(entry.written_at ? { written_at: entry.written_at } : {}) },\n }),\n });\n return Response.json({\n enriched: patch.ok,\n mood,\n ingested: ing.ok,\n // the index is a one-time setup: POST /v1/search/collections {\"collection\":\"journal\"}\n ...(ing.status === 404 ? { hint: 'create the journal index first (see the template README)' } : {}),\n });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n/** One raw-prompt sync generation; '' on any failure (enrichment is best-effort). */\nasync function generate(base: string, jwt: string, prompt: string, maxTokens: number, userId?: string): Promise<string> {\n const r = await fetch(`${base}/v1/ai/generate`, {\n method: 'POST',\n headers: H(jwt),\n body: JSON.stringify({ prompt, max_tokens: maxTokens, ...(userId ? { user_id: userId } : {}) }),\n }).catch(() => null);\n if (!r || !r.ok) return '';\n return String(((await r.json()) as { data?: { text?: string } }).data?.text ?? '');\n}\nconst clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s);\n",
17636
- "weekly-digest.ts": "// weekly-digest.ts \u2014 THE WEEKLY DIGEST (a vxil function, \xA77.3).\n//\n// Trigger: cron ('0 8 * * 1' \u2014 Mondays 08:00 UTC, delivered via the jobs\n// schedule the control-plane reconciles per cron binding). Lists the last 7\n// days of entries (written_at rides the t1 index slot, so the $gte range is\n// index-served) \u2014 ALL of them, page by page, up to MAX_PAGES (a bigger week is\n// reported `truncated: true` rather than silently dropping writers) \u2014 groups\n// them per writer, and sends each writer ONE notifications digest\n// ({ subject, paragraph } on the built-in 'transactional' template).\n//\n// Delivery notes: notifications resolves user_id against your end users \u2014 a\n// writer with no email fails that ONE send (user_email_missing) and the loop\n// continues. The per-user Idempotency-Key (envelope key + user id) makes the\n// at-least-once cron redelivery never double-send.\n\n// cron-walk: complete-per-tick \u2014 the week is read to its end each Monday (next_cursor), under MAX_PAGES.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\n/** Pages of 100 entries read per run \u2014 5,000 entries a week. */\nconst MAX_PAGES = 50;\n\ninterface EntryData { title?: string; mood?: string; user_id?: string; written_at?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n if (!cms || !notif) return Response.json({ skipped: true, reason: 'missing cms/notifications scope' });\n\n // 1. the week's entries \u2014 every page, not just the first 100 (t1-slotted\n // range; the default order pages with a plain item-id cursor).\n const since = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();\n const filter = encodeURIComponent(JSON.stringify({ written_at: { $gte: since } }));\n const items: { item_id: string; data: EntryData }[] = [];\n let cursor: string | null = null;\n let truncated = false;\n for (let page = 0; ; page++) {\n if (page >= MAX_PAGES) { truncated = true; break; }\n const res = await fetch(`${base}/v1/cms/items/entries?filter=${filter}&limit=100`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''), {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `list ${res.status}` });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: EntryData }[]; next_cursor?: string | null } };\n items.push(...(body.data?.items ?? []));\n cursor = body.data?.next_cursor ?? null;\n if (!cursor) break;\n }\n\n // 2. group per writer.\n const byUser = new Map<string, EntryData[]>();\n for (const it of items) {\n const uid = it.data.user_id;\n if (!uid) continue;\n const list = byUser.get(uid) ?? [];\n list.push(it.data);\n byUser.set(uid, list);\n }\n\n // 3. one digest send per writer (best-effort per user; the loop never aborts).\n let sent = 0;\n for (const [uid, entries] of byUser) {\n const lines = entries\n .slice(0, 10)\n .map((e) => `\u2022 ${e.title ?? 'Untitled'}${e.mood ? ` (${e.mood})` : ''}`)\n .join('\\n');\n const ok = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n 'idempotency-key': `${env.idempotency_key ?? 'weekly-digest'}:${uid}`,\n },\n body: JSON.stringify({\n user_id: uid,\n template: 'transactional',\n data: {\n subject: `Your journal week \u2014 ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`,\n paragraph: `You wrote ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'} this week:\\n${lines}`,\n },\n }),\n }).then((r) => r.ok).catch(() => false);\n if (ok) sent += 1;\n }\n\n return Response.json({ entries: items.length, writers: byUser.size, sent, truncated });\n },\n};\n"
17635
+ "ask-journal.ts": "// ask-journal.ts \u2014 \"ASK YOUR JOURNAL\" (a vxil function).\n//\n// Trigger: http \u2014 POST /v1/fn/ask-journal { question, user_id? }. Runs ONE\n// retrieval-augmented call: POST /v1/rag/answer over the `journal` index the\n// on-entry-written function keeps fed. rag retrieves top-k chunks from\n// vector-search, grounds the tenant-owned prompt, generates via the ai feature,\n// and returns the answer WITH citations pointing at the exact entries used \u2014\n// this function is a thin, scoped wrapper (rag:write only).\n//\n// In end-user mode the verified principal is propagated automatically into the\n// scoped token, so retrieval is owner-scoped; in server mode an optional\n// `user_id` rides along for per-user metering.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ question?: string; user_id?: string }>;\ninterface Citation { chunk_id?: string; doc_id?: string; score?: number }\ninterface AnswerRes { data?: { answer?: string; citations?: Citation[]; usage?: Record<string, unknown> } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const rag = env.scoped_jwts?.rag;\n if (!rag) return json({ error: 'missing rag scope' }, 403);\n\n const question = String(env.payload?.question ?? '').trim();\n if (!question) return json({ error: 'question required', example: { question: 'what made me happy last month?' } }, 400);\n\n const res = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n query: question.slice(0, 2000),\n collection: 'journal', // = rag config defaultCollection; explicit for clarity\n ...(env.payload?.user_id ? { user_id: env.payload.user_id } : {}),\n }),\n });\n if (!res.ok) {\n // A missing index is NOT a 404 here: rag's retrieve leg wraps a\n // vector-search failure as 502 retrieval_failed and attaches the\n // downstream error under error.upstream (only a 501 passes through),\n // so detect collection_not_found in the BODY, not the status. The\n // index is a one-time setup (see the template README).\n const errBody = (await res.json().catch(() => null)) as\n { error?: { code?: string; upstream?: { code?: string } } } | null;\n const code = errBody?.error?.upstream?.code ?? errBody?.error?.code;\n if (res.status === 404 || code === 'collection_not_found') {\n return json({ error: 'journal index not found', hint: 'POST /v1/search/collections {\"collection\":\"journal\"} once, then write an entry' }, 404);\n }\n return json({ error: 'answer_failed', status: res.status }, 502);\n }\n\n const body = (await res.json()) as AnswerRes;\n return json({\n answer: body.data?.answer ?? '',\n // provenance: which entries grounded the answer (doc_id = the entry's item_id).\n // `score` is a RANK value (~0.016\u20130.033 at the top) that orders the\n // citations \u2014 never threshold on it. For \"is this close enough?\", preview\n // with POST /v1/rag/search and threshold each hit's `similarity` (\u22121..1).\n sources: (body.data?.citations ?? []).map((c) => ({ entry_id: c.doc_id, score: c.score })),\n }, 200);\n },\n};\n\n// \u2500\u2500 tiny helper \u2500\u2500\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
17636
+ "on-entry-written.ts": "// on-entry-written.ts \u2014 AI ENRICHMENT ON WRITE (a vxil function).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `entries`. The hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// entry by id (through the edge, tenant-scoped), then:\n// 1. asks the ai feature (POST /v1/ai/generate, raw-prompt mode) for a\n// ONE-sentence summary and a ONE-word mood,\n// 2. PATCHes them back onto the entry (merge-patch; the summary-present LATCH\n// keeps our own write-back from re-enriching \u2014 clear `summary` to redo),\n// 3. ingests title+body into the rag retrieval index (POST /v1/rag/ingest/\n// journal \u2014 the vector-search passthrough) so ask-journal can ground on it.\n// At-least-once delivery is safe to redeliver: the summary latch skips a\n// re-enrich, the PATCH is idempotent by content, and the ingest converges \u2014\n// vector-search upserts by doc_id, so re-ingesting the same entry re-indexes\n// in place rather than duplicating.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface EntryData { title?: string; body?: string; summary?: string; mood?: string; written_at?: string; user_id?: string }\ninterface Item { data?: { data?: EntryData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const rag = env.scoped_jwts?.rag;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'entries' || !cms || !ai || !rag || !itemId) {\n return Response.json({ skipped: true });\n }\n\n // Re-fetch the entry (the payload carries only the id \u2014 never trust inline fields).\n const res = await fetch(`${base}/v1/cms/items/entries/${itemId}`, { headers: H(cms) });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const entry = ((await res.json()) as Item).data?.data ?? {};\n if (entry.summary) return Response.json({ skipped: true, reason: 'already enriched' });\n if (!entry.body) return Response.json({ skipped: true, reason: 'no body yet' });\n\n // 1. AI enrichment \u2014 two small raw-prompt generations ({ data: { text } }).\n const text = entry.body.slice(0, 6000);\n const summary = clip(await generate(base, ai,\n `Summarize this journal entry in exactly one sentence, first person:\\n\\n${text}`, 80, entry.user_id), 400);\n const moodRaw = await generate(base, ai,\n `Answer with ONE lowercase word (e.g. joyful, anxious, calm, tired) naming the dominant mood of this journal entry:\\n\\n${text}`, 8, entry.user_id);\n const mood = (moodRaw.trim().split(/\\s+/)[0] ?? '').toLowerCase().replace(/[^a-z-]/g, '').slice(0, 24);\n if (!summary) return Response.json({ skipped: true, reason: 'ai unavailable' });\n\n // 2. PATCH the derived fields back (merge-patch keys; bumps `version`).\n const patch = await fetch(`${base}/v1/cms/items/entries/${itemId}`, {\n method: 'PATCH',\n headers: H(cms),\n body: JSON.stringify({ data: { summary, ...(mood ? { mood } : {}) } }),\n });\n\n // 3. Ingest into the retrieval index (rag \u2192 vector-search passthrough, 202).\n // Idempotent by doc_id: vector-search UPSERTs on (collection, doc_id), so a\n // redelivered hook (or an edited entry) re-indexes in place.\n const ing = await fetch(`${base}/v1/rag/ingest/journal`, {\n method: 'POST',\n headers: H(rag),\n body: JSON.stringify({\n doc_id: itemId,\n ...(entry.user_id ? { user_id: entry.user_id } : {}),\n text: `${entry.title ?? ''}\\n\\n${entry.body}`,\n metadata: { ...(mood ? { mood } : {}), ...(entry.written_at ? { written_at: entry.written_at } : {}) },\n }),\n });\n return Response.json({\n enriched: patch.ok,\n mood,\n ingested: ing.ok,\n // the index is a one-time setup: POST /v1/search/collections {\"collection\":\"journal\"}\n ...(ing.status === 404 ? { hint: 'create the journal index first (see the template README)' } : {}),\n });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n/** One raw-prompt sync generation; '' on any failure (enrichment is best-effort). */\nasync function generate(base: string, jwt: string, prompt: string, maxTokens: number, userId?: string): Promise<string> {\n const r = await fetch(`${base}/v1/ai/generate`, {\n method: 'POST',\n headers: H(jwt),\n body: JSON.stringify({ prompt, max_tokens: maxTokens, ...(userId ? { user_id: userId } : {}) }),\n }).catch(() => null);\n if (!r || !r.ok) return '';\n return String(((await r.json()) as { data?: { text?: string } }).data?.text ?? '');\n}\nconst clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s);\n",
17637
+ "weekly-digest.ts": "// weekly-digest.ts \u2014 THE WEEKLY DIGEST (a vxil function).\n//\n// Trigger: cron ('0 8 * * 1' \u2014 Mondays 08:00 UTC, delivered via the jobs\n// schedule the control-plane reconciles per cron binding). Lists the last 7\n// days of entries (written_at rides the t1 index slot, so the $gte range is\n// index-served) \u2014 ALL of them, page by page, up to MAX_PAGES (a bigger week is\n// reported `truncated: true` rather than silently dropping writers) \u2014 groups\n// them per writer, and sends each writer ONE notifications digest\n// ({ subject, paragraph } on the built-in 'transactional' template).\n//\n// Delivery notes: notifications resolves user_id against your end users \u2014 a\n// writer with no email fails that ONE send (user_email_missing) and the loop\n// continues. The per-user Idempotency-Key (envelope key + user id) makes the\n// at-least-once cron redelivery never double-send.\n\n// cron-walk: complete-per-tick \u2014 the week is read to its end each Monday (next_cursor), under MAX_PAGES.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\n/** Pages of 100 entries read per run \u2014 5,000 entries a week. */\nconst MAX_PAGES = 50;\n\ninterface EntryData { title?: string; mood?: string; user_id?: string; written_at?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n if (!cms || !notif) return Response.json({ skipped: true, reason: 'missing cms/notifications scope' });\n\n // 1. the week's entries \u2014 every page, not just the first 100 (t1-slotted\n // range; the default order pages with a plain item-id cursor).\n const since = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();\n const filter = encodeURIComponent(JSON.stringify({ written_at: { $gte: since } }));\n const items: { item_id: string; data: EntryData }[] = [];\n let cursor: string | null = null;\n let truncated = false;\n for (let page = 0; ; page++) {\n if (page >= MAX_PAGES) { truncated = true; break; }\n const res = await fetch(`${base}/v1/cms/items/entries?filter=${filter}&limit=100`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''), {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `list ${res.status}` });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: EntryData }[]; next_cursor?: string | null } };\n items.push(...(body.data?.items ?? []));\n cursor = body.data?.next_cursor ?? null;\n if (!cursor) break;\n }\n\n // 2. group per writer.\n const byUser = new Map<string, EntryData[]>();\n for (const it of items) {\n const uid = it.data.user_id;\n if (!uid) continue;\n const list = byUser.get(uid) ?? [];\n list.push(it.data);\n byUser.set(uid, list);\n }\n\n // 3. one digest send per writer (best-effort per user; the loop never aborts).\n let sent = 0;\n for (const [uid, entries] of byUser) {\n const lines = entries\n .slice(0, 10)\n .map((e) => `\u2022 ${e.title ?? 'Untitled'}${e.mood ? ` (${e.mood})` : ''}`)\n .join('\\n');\n const ok = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n 'idempotency-key': `${env.idempotency_key ?? 'weekly-digest'}:${uid}`,\n },\n body: JSON.stringify({\n user_id: uid,\n template: 'transactional',\n data: {\n subject: `Your journal week \u2014 ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`,\n paragraph: `You wrote ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'} this week:\\n${lines}`,\n },\n }),\n }).then((r) => r.ok).catch(() => false);\n if (ok) sent += 1;\n }\n\n return Response.json({ entries: items.length, writers: byUser.size, sent, truncated });\n },\n};\n"
17637
17638
  }
17638
17639
  },
17639
17640
  {
@@ -17660,11 +17661,11 @@ export default defineConfig({
17660
17661
  "vxil_read_key"
17661
17662
  ],
17662
17663
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Agent Desk\" \u2014 the AGENT blueprint. A deliberately small support desk that\n// exercises the whole AI half, each piece doing exactly one job:\n//\n// \u2022 ai classify \u2192 what KIND of ticket is this? (a forced-label verdict)\n// \u2022 ai judge \u2192 how GOOD is this draft? (a forced-score verdict)\n// \u2022 vector-search\u2192 the knowledge index, kept in step with the `kb` collection\n// \u2022 rag \u2192 a grounded, CITED answer over that index\n// \u2022 copilot \u2192 the in-app assistant, with a propose \u2192 confirm action\n// catalog so a write never happens behind the user's back\n// \u2022 mcp \u2192 the same backend as TOOLS, narrowed to a least-privilege\n// agent key\n// \u2022 functions \u2192 the three deterministic steps: triage on write, draft on\n// a button, and a capability probe an agent can call to\n// discover what this backend can react to\n//\n// Everything runs on the deterministic `mock` AI provider until you add a key,\n// so the walkthrough is reproducible with no provider account.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n // The model layer. `mock` is deterministic \u2014 swap `defaultProvider` and add\n // ONE key reference to run the same config on a real model.\n ai: {\n enabled: true,\n defaultProvider: 'mock',\n // providers: { openaiKeyRef: 'openai_key' }, // then defaultProvider: 'openai'\n defaults: { model: 'mock-1', maxTokens: 512, temperature: 0 },\n cache: { ttlSeconds: 0 },\n streaming: { enabled: true },\n },\n\n // The knowledge index. `mock` embeddings need no key; auto-sync keeps the\n // index in step with the `kb` cms collection so there is no second place to\n // remember to update.\n 'vector-search': {\n enabled: true,\n embed: { provider: 'mock', dimensions: 1536 },\n chunking: { maxTokens: 512, overlap: 64 },\n hybrid: { defaultMode: 'hybrid' },\n sync: [\n {\n source: 'cms',\n cmsCollection: 'kb', // the collection you author in\n collection: 'kb', // the index rag reads\n fields: ['title', 'body'], // what gets embedded\n metadataFields: ['topic'], // \u2026and what stays filterable\n statusFilter: 'published', // drafts are never indexed\n cron: '*/15 * * * *',\n },\n ],\n },\n\n // Retrieval \u2192 grounded answer. NOTE: `defaultTemplate` names a PROMPT\n // TEMPLATE, which is a versioned row you create with\n // `POST /v1/ai/templates` \u2014 prompts are yours, not config (see the README).\n rag: {\n enabled: true,\n defaultCollection: 'kb',\n defaultTemplate: 'support-answer',\n retrieval: { topK: 5, mode: 'hybrid' },\n context: { maxTokens: 2000, strategy: 'topk' },\n citations: true,\n streaming: true,\n },\n\n // The in-app assistant. `actions.mode: 'actions'` is what turns a read-only\n // chat into one that can PROPOSE a write; `requireConfirm` is what makes\n // the human the one who commits it.\n copilot: {\n enabled: true,\n agents: {\n desk: {\n name: 'Desk',\n tone: 'Concise, factual, never speculative.',\n greeting: 'Ask me about a ticket, or about anything in the knowledge base.',\n knowledge: { collections: ['kb'], useRag: true },\n citations: true,\n grounding: 'strict', // answer only from retrieved context\n actions: {\n mode: 'actions',\n // The keys ARE tool names from the catalog \u2014 an unknown key is\n // inert, never invented. Reads run inline; writes are proposed.\n allow: {\n cms_query_items: { requireConfirm: false },\n cms_create_item: { requireConfirm: true },\n cms_run_item_action: { requireConfirm: true },\n },\n },\n guardrails: {\n maxTurnsPerSession: 20,\n rateLimitPerUserPerDay: 50,\n maxInputChars: 4000,\n refusalMessage: \"I can only answer from this workspace's knowledge base.\",\n allowGuest: false,\n },\n },\n },\n limits: { consumeCredits: false },\n widget: { enabled: false, requireAuth: true, allowedOrigins: [] },\n },\n\n // The same backend, exposed as TOOLS. `custom` + an explicit list is the\n // least-privilege posture: the agent sees these and nothing else.\n mcp: {\n enabled: true,\n exposureLevel: 'custom',\n allowToolList: [\n 'cms_query_items',\n 'cms_create_item',\n 'cms_run_item_action',\n 'ai_classify',\n 'ai_judge',\n 'rag_answer',\n 'rag_search',\n 'search_query',\n 'webhooks_event_catalog',\n 'vxil_tool_search',\n ],\n rateLimits: { toolCallsPerMin: 60 },\n branding: {\n serverName: 'Agent Desk',\n serverInstructions:\n 'Answer from the knowledge base and cite it. Classify a ticket before replying. '\n + 'Never create or modify a record without the user confirming it first.',\n },\n // Config-declared prompts the agent can pull instead of you pasting one.\n prompts: {\n triage: {\n description: 'Triage an inbound ticket end to end.',\n template:\n 'Classify ticket {{ticket_id}} with ai_classify, then use rag_answer to draft a reply '\n + 'grounded in the knowledge base. Show me the draft and its citations before writing anything.',\n arguments: [{ name: 'ticket_id', description: 'the cms item id', required: true }],\n },\n },\n },\n\n // Enabled for ONE reason in this blueprint: the event CATALOG \u2014 the\n // machine-readable list of everything this backend can emit, which is how\n // an agent discovers what it is able to react to.\n webhooks: { enabled: true, maxSubscriptions: 5 },\n\n cms: {},\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n tickets: {\n singular: 'ticket',\n // ONE button per row. Pressing \"Draft reply\" runs the rag + judge step\n // once, as a human-initiated action \u2014 not on every write.\n actions: [{ key: 'draft_reply', label: 'Draft reply', fn: 'draft-reply' }],\n fields: {\n subject: { type: 'string', required: true, indexSlot: 's1' },\n requester: { type: 'string', indexSlot: 's2' },\n // written by the triage function, never by hand\n category: { type: 'string', indexSlot: 's3' }, // billing | bug | how_to | other\n state: { type: 'string', indexSlot: 's4' }, // open | drafted | closed\n draft_score: { type: 'int', indexSlot: 'n1' }, // the judge's score, 0\u201310\n created_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n draft: { type: 'text' }, // the last grounded draft reply\n },\n },\n\n kb: {\n singular: 'kb_article',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', unique: true, indexSlot: 's2' },\n topic: { type: 'string', indexSlot: 's3' },\n published_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // TRIAGE ON WRITE. Fires on every new ticket, re-fetches it (a hook\n // delivery carries ids, not the row), asks for a forced-label verdict, and\n // writes the label back. One model call, one field.\n 'triage-ticket': {\n entry: './functions/triage-ticket.ts',\n trigger: { kind: 'cmsHook', collection: 'tickets', event: 'beforeCreate' },\n scopes: ['cms:read', 'cms:write', 'ai:write'],\n egressAllow: [],\n },\n\n // DRAFT ON DEMAND. Invoked by the `draft_reply` record action: retrieve \u2192\n // ground \u2192 answer with citations, then score the draft against a rubric\n // before a human ever sees it.\n 'draft-reply': {\n entry: './functions/draft-reply.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'rag:write', 'ai:write'],\n egressAllow: [],\n },\n\n // CAPABILITY DISCOVERY. Returns the event names this backend can emit,\n // folded to the features it actually has on \u2014 the answer to an agent's\n // \"what can I react to here?\". The catalog is a control-plane read, so it\n // uses a narrow BYO key rather than the function's feature callback.\n 'agent-capabilities': {\n entry: './functions/agent-capabilities.ts',\n trigger: { kind: 'http' },\n scopes: [],\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n },\n },\n\n secrets: {\n vxil_read_key: {\n feature: 'functions',\n description: 'a vxil API key of this backend holding ONLY features:read + webhooks:read',\n },\n // openai_key: { feature: 'ai', description: 'BYO model key \u2014 then set ai.defaultProvider' },\n },\n\n seed: {\n cms: [\n {\n collection: 'kb',\n items: [\n {\n title: 'How refunds work',\n slug: 'how-refunds-work',\n topic: 'billing',\n published_at: '2026-02-10T09:00:00Z',\n body:\n 'A refund is issued to the original payment method within 14 days of purchase. '\n + 'Ask the customer for the order id, confirm the purchase date, then issue the refund '\n + 'from the billing screen. Refunds are not available after 14 days.',\n },\n ],\n },\n ],\n },\n});\n",
17663
- "readme": '# Agent Desk (ai)\n\nThe whole AI half of vxil on a deliberately small support desk \u2014 two collections, three functions,\nand one idea per feature. If you have been trying to work out where `ai`, `rag`, `vector-search`,\n`copilot` and `mcp` differ, this is the blueprint that answers it by making each one do exactly its\nown job.\n\n```bash\nvxil init --template agent-desk\nvxil quickstart # or `vxil link <slug>`\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key\nvxil push # collections + the three functions\n```\n\nEverything runs on the deterministic **`mock`** model provider, so the walkthrough below is\nreproducible with no provider account and no spend. Swapping in a real model is one config line and\none secret \u2014 nothing else in this blueprint changes.\n\n`vxil_read_key` is a key **of this same backend** carrying only `features:read` and `webhooks:read`;\nthe capability probe uses it (dashboard \u2192 API keys \u2192 create, tick those two and nothing else).\n\n## One idea per feature\n\n| Feature | The one thing it does here | Why it is not one of the others |\n|---|---|---|\n| `ai` classify | pick exactly one label from a fixed set | a chat prompt can return a paragraph; a classifier cannot |\n| `ai` judge | score a draft as an integer on a fixed scale | the model that writes is not the authority on whether the writing is good |\n| `vector-search` | hold the knowledge index, synced from `kb` | retrieval, not generation \u2014 no prompt lives here |\n| `rag` | answer **only** from what was retrieved, with citations | the pipeline; the *prompt* is a template you own |\n| `copilot` | the in-app assistant: propose a write, a human confirms | it is a composition over the four above, not a fifth model |\n| `mcp` | the same backend, as tools, narrowed per key | an agent\'s *interface*, not an agent |\n| `functions` | the deterministic steps around the model calls | the parts that must not be creative |\n\n## Prompts are yours, not config\n\n`rag.defaultTemplate: \'support-answer\'` names a **prompt template**, which is a versioned row you\ncreate over the API \u2014 deliberately not a config leaf, because a prompt is the part of the product\nyou iterate on hourly. Create it before the first answer:\n\n```bash\nvxil api POST /v1/ai/templates --data \'{\n "template": "support-answer",\n "system": "You are a support agent. Answer ONLY from the context. If the context does not contain the answer, say you do not know.",\n "user": "Context:\\n{{context}}\\n\\nCustomer question:\\n{{query}}\\n\\nWrite a short, direct reply."\n}\'\n# 201 { "data": { "template": "support-answer", "version": 1 } }\n```\n\nRe-POST the same name and you get version 2 \u2014 old versions stay pinnable. `{{query}}` and\n`{{context}}` are what the retrieval step fills in. **A grounded answer with no template is a 404**,\nso this is step zero, not an optional flourish.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `ai:read ai:write rag:read rag:write vector-search:read\nvector-search:write cms:read cms:write copilot:read copilot:write webhooks:read functions:invoke\nfeatures:read`.\n\n**1. The index.** The `kb` cms collection is what you author in; the `kb` vector collection is what\nretrieval reads. Create the index, then push an article into it:\n\n```bash\nvxil api POST /v1/search/collections --data \'{"collection":"kb","dimensions":1536}\'\n# 201 { "data": { "collection": "kb", "dimensions": 1536, "backend": "\u2026" } }\n# (`vector-search.sync` also reconciles one scheduled job per entry \u2014 you can see it in\n# `GET /v1/jobs/schedules` as `vs-sync:cms~kb~kb`, on the cron you declared.)\n\nvxil api POST /v1/rag/ingest/kb --data \'{\n "doc_id": "how-refunds-work",\n "text": "A refund is issued to the original payment method within 14 days of purchase. Ask the customer for the order id, confirm the purchase date, then issue the refund from the billing screen. Refunds are not available after 14 days.",\n "metadata": { "topic": "billing" }\n}\'\n# 202 { "data": { "doc_id": "how-refunds-work", "status": "indexed", "chunks": 1, "embedding_tokens": \u2026 } }\n```\n\n`POST /v1/rag/ingest/{collection}` is a convenience: a key holding only `rag:write` can fill the\nindex without also holding a vector-search scope.\n\nYou do not have to remember to do that twice, though \u2014 `vector-search.sync` in `vxil.config.ts`\ndeclares the `kb` cms collection as a source, so published articles are embedded on a schedule and\nthe index never silently drifts from the content. The direct ingest above just saves you the wait.\n\n**2. Classification, on every new ticket.** Create one and watch the hook:\n\n```bash\nvxil api POST /v1/cms/items/tickets --data \'{"data":{"subject":"Billing: charged twice this month","requester":"u_ana","state":"open","body":"My card was charged twice on the 3rd. Can I get one of them back?"}}\'\n# 201 { "data": { "item_id": "itm_\u2026", \u2026 } }\n\n# a moment later\nvxil api GET /v1/cms/items/tickets/itm_\u2026\n# 200 \u2026 "data": { "subject": "Billing: charged twice this month", "category": "billing", "state": "open", \u2026 }\n```\n\n`triage-ticket` fired on the write, re-fetched the row (a hook delivery carries ids, not the\ndocument), and asked for a **forced-label verdict**:\n\n```bash\nvxil api POST /v1/ai/classify --data \'{"input":"Billing: charged twice this month","labels":["billing","bug","how_to","other"]}\'\n# 200 { "data": { "generation_id": "gen_\u2026", "label": "billing", "confidence": 0.9,\n# "rationale": "\u2026", "usage": { \u2026 }, "cached": false } }\n```\n\nThe label set is part of the request, so the answer is constrained to it by the schema \u2014 the model\ncannot invent a fifth category or reply with a sentence. The function is also idempotent by\ninspection: a ticket that already has a `category` is skipped, because hook delivery is\nat-least-once and a redelivery should not cost another model call.\n\n**3. A grounded, cited draft \u2014 and a second opinion on it.** Press the record\'s button:\n\n```bash\nvxil api POST /v1/cms/items/tickets/itm_\u2026/actions/draft_reply\n# 200 { "data": { "collection": "tickets", "item_id": "itm_\u2026", "action": "draft_reply",\n# "fn": "draft-reply",\n# "result": { "draft": "\u2026", "score": 10, "verdict": "pass",\n# "citations": [ { "chunk_id": "how-refunds-work#0",\n# "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "written": true } } }\n```\n\nTwo calls happened inside, and the split is the lesson:\n\n```bash\nvxil api POST /v1/rag/answer --data \'{"query":"My card was charged twice. Can I get one back?","collection":"kb","top_k":5,"stream":false}\'\n# 200 { "data": { "answer": "\u2026",\n# "citations": [ { "chunk_id": "how-refunds-work#0", "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "usage": { "retrieval_ms": 21, "retrieved": 1, "used": 1, \u2026 }, "finish": "stop" } }\n\nvxil api POST /v1/ai/judge --data \'{\n "input": "My card was charged twice. Can I get one back?",\n "candidate": "Refunds go back to the original payment method within 14 days of purchase.",\n "criteria": [ { "name": "answers the question asked", "weight": 2 },\n { "name": "is supported by the cited text", "weight": 2 } ],\n "scale": { "min": 0, "max": 10 } }\'\n# 200 { "data": { "generation_id": "gen_\u2026", "score": 10, "verdict": "pass", "rationale": "\u2026", \u2026 } }\n```\n\n`stream: false` is load-bearing. With streaming on (the default), this route answers with a\n`generation_id`, a channel, a token and a `resume_path` for a browser to attach to \u2014 the citations\narrive immediately and the text streams. A server-side step wants the finished text, so it asks for\nit. Getting this wrong is a silent empty draft, not an error.\n\n`citations` are the chunks that actually **survived the context budget** \u2014 not everything retrieved.\nThat distinction is what makes them auditable: every sentence in the draft is traceable to text in\nthe list. And the score is a forced integer on a fixed scale, so drafts are comparable to each\nother rather than each getting its own adjective.\n\nNothing was sent to a customer. The action writes `draft` and `draft_score` onto the ticket and\nstops \u2014 the last step is a person.\n\n**4. The assistant: propose, then confirm.** The copilot answers from the same index and, when a\nturn would *write*, stops and asks:\n\n```bash\nvxil api POST /v1/copilot/desk/messages --data \'{"user_id":"u_agent","message":"What is our refund window?"}\'\n# 200 { "data": { "conversation_id": "cnv_\u2026", "message_id": "msg_\u2026",\n# "answer": "Refunds are available within 14 days of purchase\u2026",\n# "action_status": "none", "citations": [ \u2026 ], \u2026 } }\n\nvxil api POST /v1/copilot/desk/messages --data \'{"conversation_id":"cnv_\u2026","user_id":"u_agent","message":"Open a ticket for Ana about the double charge."}\'\n# 200 { "data": { "message_id": "msg_\u2026", "action_status": "proposed",\n# "proposal": { "message_id": "msg_\u2026", "tool": "cms_create_item",\n# "args": { "collection": "tickets", "data": { "subject": "\u2026", \u2026 } },\n# "feature": "cms", "proposed_at": "\u2026", "require_confirm": true,\n# "confirm_path": "/v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm" },\n# \u2026 } }\n```\n\nOn the **mock** provider that second turn answers `action_status: "none"` \u2014 the mock does not decide\nto call a tool on its own. Steer it with the marker the platform\'s own end-to-end tests use, and the\nturn produces a real proposal you can confirm:\n\n```text\nOpen a ticket for Ana about the double charge.\n[[tool_call:cms_create_item {"collection":"tickets","data":{"subject":"Double charge for Ana","requester":"u_ana","state":"open"}}]]\n```\n\nNothing has been written yet. The proposal names the tool and the exact arguments, and hands you\nthe confirm path. Commit it:\n\n```bash\nvxil api POST /v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm\n# 200 { "data": { "message_id": "msg_\u2026", "proposal_message_id": "msg_\u2026", "action_status": "confirmed",\n# "confirmed_at": "\u2026", "result": { "data": { "item_id": "itm_\u2026", \u2026 } } } }\n```\n\nConfirm takes **no body** \u2014 the ids in the path are the whole request, which is what makes the\nlatch tamper-proof: you cannot confirm a *different* write than the one you were shown. Call it\ntwice and the second answers `already: true`. Wait fifteen minutes and it is\n`410 proposal_expired`. And the permission check runs **again at confirm time**, so a scope revoked\nbetween proposal and confirm stops the write.\n\nThe keys under `copilot.agents.desk.actions.allow` are tool names from the catalog \u2014\n`cms_query_items` (a read, run inline) and `cms_create_item` / `cms_run_item_action` (writes,\nproposed). An unknown key there is inert, never invented.\n\n**5. The same backend, as tools.** Point an agent at it:\n\n```bash\nvxil mcp install --client claude --scopes features:read,cms:read,ai:write,rag:read,vector-search:read,webhooks:read\n```\n\nThat mints a dedicated, `agent`-tagged, revocable key and writes the MCP server entry for your\nclient. Three layers decide what the agent can do, and they compose:\n\n1. **`mcp.exposureLevel: \'custom\'` + `allowToolList`** in this config \u2014 the tenant-wide surface.\n2. **the key\'s scopes** \u2014 what the underlying REST route will accept.\n3. **the key\'s `allowed_tools` / `denied_tools`** \u2014 a per-key narrowing on top, editable after\n minting without rotating the key.\n\n`features:read` is load-bearing: without it the policy probe (`GET /v1/config/mcp`) is refused and\nthe agent sees **zero** tools with no obvious error. Mint least privilege, but not less than that.\n\n**6. What can I react to here?** The last function answers the question an agent always has to ask\na human today:\n\n```bash\nvxil functions invoke agent-capabilities\n# { "catalog_events": 170,\n# "enabled_features": [ "ai", "cms", "copilot", "functions", "mcp", "rag", "vector-search", "webhooks" ],\n# "reactable_prefixes": [ { "prefix": "cms.item.", "count": \u2026 }, { "prefix": "ai.", "count": \u2026 }, \u2026 ],\n# "failure_events": [ "job.dead_lettered", "jobs.schedule.missed",\n# "webhooks.delivery.dead_lettered", \u2026 ],\n# "how_to_subscribe": "POST /v1/webhooks/subscriptions \u2026" }\n```\n\nIt reads `GET /v1/webhooks/events/catalog` \u2014 the machine-readable list of every lifecycle and\nfailure event the platform writes, with a prefix roll-up \u2014 and folds it against the features this\nbackend actually has on. The agent can call the catalog itself, too: `webhooks_event_catalog` is in\nthe tool list above, which is the difference between an agent that *has* tools and one that can\n**discover** what the system will tell it.\n\nActing on that discovery is one call with a key that carries `webhooks:write` \u2014 deliberately not\nthe read-only key this function holds:\n\n```bash\nvxil api POST /v1/webhooks/subscriptions --data \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["cms.item.","ai."]}\'\n```\n\n## What to learn from this\n\n- **Forcing the shape is the feature.** Classify returns one of *your* labels; judge returns an\n integer in *your* range. Most "the model went off the rails" problems are a missing schema, not a\n missing instruction.\n- **Two passes beat one long prompt.** Writing and evaluating are different jobs, and separating\n them gives you a number you can threshold, chart and regress against.\n- **Grounding is a pipeline, not a prompt trick.** Retrieval, a context budget, and citations of\n the chunks that survived it \u2014 the answer is auditable because the pipeline kept the receipts.\n- **Propose \u2192 confirm is where agent safety actually lives.** Not in a system prompt asking the\n model to be careful: in a latch that persists the exact arguments, re-checks permission at commit\n time, expires, and executes at most once.\n- **Least privilege for an agent is three layers, not one.** The tenant\'s exposure list, the key\'s\n scopes, and the key\'s per-tool narrowing \u2014 each can be tightened without touching the others.\n- **An agent should be able to ask the backend what it can do.** A tool catalog and an event\n catalog are both machine-readable for the same reason: the alternative is a prompt that goes stale\n the next time you ship.\n\n**Pairs with:** `templates/ai-journal/` (enrichment on write, and asking your own data questions)\nand `templates/helpdesk/` (the same desk without the AI half).\n',
17664
+ "readme": '# Agent Desk (ai)\n\nThe whole AI half of vxil on a deliberately small support desk \u2014 two collections, three functions,\nand one idea per feature. If you have been trying to work out where `ai`, `rag`, `vector-search`,\n`copilot` and `mcp` differ, this is the blueprint that answers it by making each one do exactly its\nown job.\n\n```bash\nvxil init --template agent-desk\nvxil quickstart --env staging --no-push # or `vxil link <slug> --env staging`\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key\nvxil push # collections + the three functions\n```\n\n> **Plan note.** The three functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\nEverything runs on the deterministic **`mock`** model provider, so the walkthrough below is\nreproducible with no provider account and no spend. Swapping in a real model is one config line and\none secret \u2014 nothing else in this blueprint changes.\n\n`vxil_read_key` is a key **of this same backend** carrying only `features:read` and `webhooks:read`;\nthe capability probe uses it (dashboard \u2192 API keys \u2192 create, tick those two and nothing else).\n\n## One idea per feature\n\n| Feature | The one thing it does here | Why it is not one of the others |\n|---|---|---|\n| `ai` classify | pick exactly one label from a fixed set | a chat prompt can return a paragraph; a classifier cannot |\n| `ai` judge | score a draft as an integer on a fixed scale | the model that writes is not the authority on whether the writing is good |\n| `vector-search` | hold the knowledge index, synced from `kb` | retrieval, not generation \u2014 no prompt lives here |\n| `rag` | answer **only** from what was retrieved, with citations | the pipeline; the *prompt* is a template you own |\n| `copilot` | the in-app assistant: propose a write, a human confirms | it is a composition over the four above, not a fifth model |\n| `mcp` | the same backend, as tools, narrowed per key | an agent\'s *interface*, not an agent |\n| `functions` | the deterministic steps around the model calls | the parts that must not be creative |\n\n## Prompts are yours, not config\n\n`rag.defaultTemplate: \'support-answer\'` names a **prompt template**, which is a versioned row you\ncreate over the API \u2014 deliberately not a config leaf, because a prompt is the part of the product\nyou iterate on hourly. Create it before the first answer:\n\n```bash\nvxil api POST /v1/ai/templates --data \'{\n "template": "support-answer",\n "system": "You are a support agent. Answer ONLY from the context. If the context does not contain the answer, say you do not know.",\n "user": "Context:\\n{{context}}\\n\\nCustomer question:\\n{{query}}\\n\\nWrite a short, direct reply."\n}\'\n# 201 { "data": { "template": "support-answer", "version": 1 } }\n```\n\nRe-POST the same name and you get version 2 \u2014 old versions stay pinnable. `{{query}}` and\n`{{context}}` are what the retrieval step fills in. **A grounded answer with no template is a 404**,\nso this is step zero, not an optional flourish.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `ai:read ai:write rag:read rag:write vector-search:read\nvector-search:write cms:read cms:write copilot:read copilot:write webhooks:read functions:invoke\nfeatures:read`.\n\n**1. The index.** The `kb` cms collection is what you author in; the `kb` vector collection is what\nretrieval reads. Create the index, then push an article into it:\n\n```bash\nvxil api POST /v1/search/collections --data \'{"collection":"kb","dimensions":1536}\'\n# 201 { "data": { "collection": "kb", "dimensions": 1536, "backend": "\u2026" } }\n# (`vector-search.sync` also reconciles one scheduled job per entry \u2014 you can see it in\n# `GET /v1/jobs/schedules` as `vs-sync:cms~kb~kb`, on the cron you declared.)\n\nvxil api POST /v1/rag/ingest/kb --data \'{\n "doc_id": "how-refunds-work",\n "text": "A refund is issued to the original payment method within 14 days of purchase. Ask the customer for the order id, confirm the purchase date, then issue the refund from the billing screen. Refunds are not available after 14 days.",\n "metadata": { "topic": "billing" }\n}\'\n# 202 { "data": { "doc_id": "how-refunds-work", "status": "indexed", "chunks": 1, "embedding_tokens": \u2026 } }\n```\n\n`POST /v1/rag/ingest/{collection}` is a convenience: a key holding only `rag:write` can fill the\nindex without also holding a vector-search scope.\n\nYou do not have to remember to do that twice, though \u2014 `vector-search.sync` in `vxil.config.ts`\ndeclares the `kb` cms collection as a source, so published articles are embedded on a schedule and\nthe index never silently drifts from the content. The direct ingest above just saves you the wait.\n\n**2. Classification, on every new ticket.** Create one and watch the hook:\n\n```bash\nvxil api POST /v1/cms/items/tickets --data \'{"data":{"subject":"Billing: charged twice this month","requester":"u_ana","state":"open","body":"My card was charged twice on the 3rd. Can I get one of them back?"}}\'\n# 201 { "data": { "item_id": "itm_\u2026", \u2026 } }\n\n# a moment later\nvxil api GET /v1/cms/items/tickets/itm_\u2026\n# 200 \u2026 "data": { "subject": "Billing: charged twice this month", "category": "billing", "state": "open", \u2026 }\n```\n\n`triage-ticket` fired on the write, re-fetched the row (a hook delivery carries ids, not the\ndocument), and asked for a **forced-label verdict**:\n\n```bash\nvxil api POST /v1/ai/classify --data \'{"input":"Billing: charged twice this month","labels":["billing","bug","how_to","other"]}\'\n# 200 { "data": { "generation_id": "gen_\u2026", "label": "billing", "confidence": 0.9,\n# "rationale": "\u2026", "usage": { \u2026 }, "cached": false } }\n```\n\nThe label set is part of the request, so the answer is constrained to it by the schema \u2014 the model\ncannot invent a fifth category or reply with a sentence. The function is also idempotent by\ninspection: a ticket that already has a `category` is skipped, because hook delivery is\nat-least-once and a redelivery should not cost another model call.\n\n**3. A grounded, cited draft \u2014 and a second opinion on it.** Press the record\'s button:\n\n```bash\nvxil api POST /v1/cms/items/tickets/itm_\u2026/actions/draft_reply\n# 200 { "data": { "collection": "tickets", "item_id": "itm_\u2026", "action": "draft_reply",\n# "fn": "draft-reply",\n# "result": { "draft": "\u2026", "score": 10, "verdict": "pass",\n# "citations": [ { "chunk_id": "how-refunds-work#0",\n# "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "written": true } } }\n```\n\nA citation\'s `score` is a **rank** value (about 0.016\u20130.033 for the top hit), not a relevance\nmeasure \u2014 it orders the hits. To decide whether a chunk is close enough to the question, preview\nretrieval with `POST /v1/rag/search` and threshold each hit\'s `similarity` (cosine, \u22121..1) instead.\n\nTwo calls happened inside, and the split is the lesson:\n\n```bash\nvxil api POST /v1/rag/answer --data \'{"query":"My card was charged twice. Can I get one back?","collection":"kb","top_k":5,"stream":false}\'\n# 200 { "data": { "answer": "\u2026",\n# "citations": [ { "chunk_id": "how-refunds-work#0", "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "usage": { "retrieval_ms": 21, "retrieved": 1, "used": 1, \u2026 }, "finish": "stop" } }\n\nvxil api POST /v1/ai/judge --data \'{\n "input": "My card was charged twice. Can I get one back?",\n "candidate": "Refunds go back to the original payment method within 14 days of purchase.",\n "criteria": [ { "name": "answers the question asked", "weight": 2 },\n { "name": "is supported by the cited text", "weight": 2 } ],\n "scale": { "min": 0, "max": 10 } }\'\n# 200 { "data": { "generation_id": "gen_\u2026", "score": 10, "verdict": "pass", "rationale": "\u2026", \u2026 } }\n```\n\n`stream: false` is load-bearing. With streaming on (the default), this route answers with a\n`generation_id`, a channel, a token and a `resume_path` for a browser to attach to \u2014 the citations\narrive immediately and the text streams. A server-side step wants the finished text, so it asks for\nit. Getting this wrong is a silent empty draft, not an error.\n\n`citations` are the chunks that actually **survived the context budget** \u2014 not everything retrieved.\nThat distinction is what makes them auditable: every sentence in the draft is traceable to text in\nthe list. And the score is a forced integer on a fixed scale, so drafts are comparable to each\nother rather than each getting its own adjective.\n\nNothing was sent to a customer. The action writes `draft` and `draft_score` onto the ticket and\nstops \u2014 the last step is a person.\n\n**4. The assistant: propose, then confirm.** The copilot answers from the same index and, when a\nturn would *write*, stops and asks:\n\n```bash\nvxil api POST /v1/copilot/desk/messages --data \'{"user_id":"u_agent","message":"What is our refund window?"}\'\n# 200 { "data": { "conversation_id": "cnv_\u2026", "message_id": "msg_\u2026",\n# "answer": "Refunds are available within 14 days of purchase\u2026",\n# "action_status": "none", "citations": [ \u2026 ], \u2026 } }\n\nvxil api POST /v1/copilot/desk/messages --data \'{"conversation_id":"cnv_\u2026","user_id":"u_agent","message":"Open a ticket for Ana about the double charge."}\'\n# 200 { "data": { "message_id": "msg_\u2026", "action_status": "proposed",\n# "proposal": { "message_id": "msg_\u2026", "tool": "cms_create_item",\n# "args": { "collection": "tickets", "data": { "subject": "\u2026", \u2026 } },\n# "feature": "cms", "proposed_at": "\u2026", "require_confirm": true,\n# "confirm_path": "/v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm" },\n# \u2026 } }\n```\n\nOn the **mock** provider that second turn answers `action_status: "none"` \u2014 the mock does not decide\nto call a tool on its own. Steer it with the marker the platform\'s own end-to-end tests use, and the\nturn produces a real proposal you can confirm:\n\n```text\nOpen a ticket for Ana about the double charge.\n[[tool_call:cms_create_item {"collection":"tickets","data":{"subject":"Double charge for Ana","requester":"u_ana","state":"open"}}]]\n```\n\nNothing has been written yet. The proposal names the tool and the exact arguments, and hands you\nthe confirm path. Commit it:\n\n```bash\nvxil api POST /v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm\n# 200 { "data": { "message_id": "msg_\u2026", "proposal_message_id": "msg_\u2026", "action_status": "confirmed",\n# "confirmed_at": "\u2026", "result": { "data": { "item_id": "itm_\u2026", \u2026 } } } }\n```\n\nConfirm takes **no body** \u2014 the ids in the path are the whole request, which is what makes the\nlatch tamper-proof: you cannot confirm a *different* write than the one you were shown. Call it\ntwice and the second answers `already: true`. Wait fifteen minutes and it is\n`410 proposal_expired`. And the permission check runs **again at confirm time**, so a scope revoked\nbetween proposal and confirm stops the write.\n\nThe keys under `copilot.agents.desk.actions.allow` are tool names from the catalog \u2014\n`cms_query_items` (a read, run inline) and `cms_create_item` / `cms_run_item_action` (writes,\nproposed). An unknown key there is inert, never invented.\n\n**5. The same backend, as tools.** Point an agent at it:\n\n```bash\nvxil mcp install --client claude --scopes features:read,cms:read,ai:write,rag:read,vector-search:read,webhooks:read\n```\n\nThat mints a dedicated, `agent`-tagged, revocable key and writes the MCP server entry for your\nclient. Three layers decide what the agent can do, and they compose:\n\n1. **`mcp.exposureLevel: \'custom\'` + `allowToolList`** in this config \u2014 the tenant-wide surface.\n2. **the key\'s scopes** \u2014 what the underlying REST route will accept.\n3. **the key\'s `allowed_tools` / `denied_tools`** \u2014 a per-key narrowing on top, editable after\n minting without rotating the key.\n\n`features:read` is load-bearing: without it the policy probe (`GET /v1/config/mcp`) is refused and\nthe agent sees **zero** tools with no obvious error. Mint least privilege, but not less than that.\n\n**6. What can I react to here?** The last function answers the question an agent always has to ask\na human today:\n\n```bash\nvxil functions invoke agent-capabilities\n# { "catalog_events": 170,\n# "enabled_features": [ "ai", "cms", "copilot", "functions", "mcp", "rag", "vector-search", "webhooks" ],\n# "reactable_prefixes": [ { "prefix": "cms.item.", "count": \u2026 }, { "prefix": "ai.", "count": \u2026 }, \u2026 ],\n# "failure_events": [ "job.dead_lettered", "jobs.schedule.missed",\n# "webhooks.delivery.dead_lettered", \u2026 ],\n# "how_to_subscribe": "POST /v1/webhooks/subscriptions \u2026" }\n```\n\nIt reads `GET /v1/webhooks/events/catalog` \u2014 the machine-readable list of every lifecycle and\nfailure event the platform writes, with a prefix roll-up \u2014 and folds it against the features this\nbackend actually has on. The agent can call the catalog itself, too: `webhooks_event_catalog` is in\nthe tool list above, which is the difference between an agent that *has* tools and one that can\n**discover** what the system will tell it.\n\nActing on that discovery is one call with a key that carries `webhooks:write` \u2014 deliberately not\nthe read-only key this function holds:\n\n```bash\nvxil api POST /v1/webhooks/subscriptions --data \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["cms.item.","ai."]}\'\n```\n\n## What to learn from this\n\n- **Forcing the shape is the feature.** Classify returns one of *your* labels; judge returns an\n integer in *your* range. Most "the model went off the rails" problems are a missing schema, not a\n missing instruction.\n- **Two passes beat one long prompt.** Writing and evaluating are different jobs, and separating\n them gives you a number you can threshold, chart and regress against.\n- **Grounding is a pipeline, not a prompt trick.** Retrieval, a context budget, and citations of\n the chunks that survived it \u2014 the answer is auditable because the pipeline kept the receipts.\n- **Propose \u2192 confirm is where agent safety actually lives.** Not in a system prompt asking the\n model to be careful: in a latch that persists the exact arguments, re-checks permission at commit\n time, expires, and executes at most once.\n- **Least privilege for an agent is three layers, not one.** The tenant\'s exposure list, the key\'s\n scopes, and the key\'s per-tool narrowing \u2014 each can be tightened without touching the others.\n- **An agent should be able to ask the backend what it can do.** A tool catalog and an event\n catalog are both machine-readable for the same reason: the alternative is a prompt that goes stale\n the next time you ship.\n\n**Pairs with:** `templates/ai-journal/` (enrichment on write, and asking your own data questions)\nand `templates/helpdesk/` (the same desk without the AI half).\n',
17664
17665
  "functions": {
17665
17666
  "agent-capabilities.ts": "// agent-capabilities.ts \u2014 \"WHAT CAN I REACT TO HERE?\" (a vxil function).\n//\n// Trigger: http. An agent (or your own onboarding screen) calls this once and\n// learns, from the backend itself, what this workspace can emit \u2014 instead of a\n// human pasting a list into a prompt that goes stale the next release.\n//\n// Two reads, folded together:\n// \u2022 `GET /v1/webhooks/events/catalog` \u2014 the machine-readable list of every\n// lifecycle and failure event the platform writes, with a `prefixes` roll-up\n// you can subscribe to directly.\n// \u2022 `GET /v1/features` \u2014 which features THIS backend actually has on.\n// The answer is the intersection: the prefixes worth subscribing to here.\n//\n// WHY A KEY AND NOT THE FUNCTION'S OWN CALLBACK: a function's scoped callback\n// covers the feature APIs (cms, ai, rag, \u2026). The event catalog and the feature\n// list are platform reads, so this uses the narrowest key that can reach them \u2014\n// one holding only `features:read` and `webhooks:read`, stored as a secret,\n// resolved per invocation, revocable in one click without a redeploy.\n//\n// To actually SUBSCRIBE, POST to /v1/webhooks/subscriptions with\n// { target_url, event_prefixes } using a key that carries `webhooks:write` \u2014\n// deliberately NOT this one (see the README).\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n/** prefix segment \u2192 the feature key it belongs to, where the names differ. */\nconst PREFIX_FEATURE: Record<string, string> = {\n job: 'jobs', jobs: 'jobs', user: 'auth', auth: 'auth', session: 'auth',\n org: 'orgs', orgs: 'orgs', rate_limits: 'rate-limits', feeds: 'activity-feed',\n 'vector-search': 'vector-search', functions: 'functions',\n};\n\ntype Env = HttpFunctionEnvelope<{ all?: boolean }>;\ninterface CatalogEvent { name?: string; feature?: string; level?: string }\ninterface Prefix { prefix?: string; count?: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const key = env.secrets?.vxil_read_key;\n if (!key) {\n return Response.json(\n { error: 'missing_secret', message: 'set the vxil_read_key secret first' },\n { status: 503 },\n );\n }\n const h = { authorization: `Bearer ${key}` };\n\n const [catRes, featRes] = await Promise.all([\n fetch(`${base}/v1/webhooks/events/catalog`, { headers: h }),\n fetch(`${base}/v1/features`, { headers: h }),\n ]);\n if (!catRes.ok) {\n return Response.json({ error: 'catalog_unavailable', status: catRes.status }, { status: 502 });\n }\n const cat = ((await catRes.json()) as {\n data?: { count?: number; events?: CatalogEvent[]; prefixes?: Prefix[] };\n }).data ?? {};\n const enabled = new Set(\n featRes.ok\n ? ((await featRes.json()) as { data?: { features?: string[] } }).data?.features ?? []\n : [],\n );\n\n const all = env.payload?.all === true;\n const prefixes = (cat.prefixes ?? []).filter((p) => {\n if (all || enabled.size === 0) return true;\n const head = String(p.prefix ?? '').replace(/\\.$/, '');\n return enabled.has(PREFIX_FEATURE[head] ?? head);\n });\n\n // The failure half is the half worth wiring first: it is what tells you the\n // backend is unhappy before a customer does.\n const failures = (cat.events ?? [])\n .filter((e) => e.level === 'failure')\n .map((e) => e.name)\n .filter((n): n is string => typeof n === 'string')\n .sort();\n\n return Response.json({\n catalog_events: cat.count ?? (cat.events ?? []).length,\n enabled_features: [...enabled].sort(),\n reactable_prefixes: prefixes,\n failure_events: failures,\n how_to_subscribe:\n 'POST /v1/webhooks/subscriptions { \"target_url\": \"https://\u2026\", \"event_prefixes\": [\"job.\", \"cms.item.\"] } '\n + 'with a key carrying webhooks:write',\n });\n },\n};\n",
17666
- "draft-reply.ts": "// draft-reply.ts \u2014 RETRIEVE \u2192 GROUND \u2192 SCORE (a vxil function).\n//\n// Trigger: the per-record action `draft_reply` on `tickets`. The action envelope\n// carries the WHOLE row, so this step needs no re-fetch:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// Three calls, three jobs, in order:\n// 1. `POST /v1/rag/answer` \u2014 retrieve from the `kb` index and answer ONLY from\n// what came back, returning the chunks it used as citations. A grounded\n// answer you can audit beats a confident one you cannot.\n// 2. `POST /v1/ai/judge` \u2014 score that draft against a rubric, as an integer on\n// a fixed scale. The model that writes is not the authority on whether the\n// writing is good; a second, schema-forced pass is.\n// 3. one PATCH \u2014 persist the draft + its score so a human decides what to send.\n//\n// Nothing here sends anything to a customer. The last step is always a person.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst CRITERIA = [\n { name: 'answers the question asked', weight: 2 },\n { name: 'is supported by the cited knowledge-base text', weight: 2 },\n { name: 'is concise and free of speculation', weight: 1 },\n];\n\ntype Env = HttpFunctionEnvelope<{\n collection?: string;\n item_id?: string;\n item?: { data?: { subject?: string; body?: string; category?: string } };\n}>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const rag = env.scoped_jwts?.rag;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (!cms || !rag || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ error: 'bad_request', message: 'not a tickets action' }, { status: 400 });\n }\n\n const t = env.payload?.item?.data ?? {};\n const question = `${t.subject ?? ''}\\n\\n${t.body ?? ''}`.trim();\n if (!question) return Response.json({ error: 'empty_ticket' }, { status: 422 });\n\n // 1. GROUNDED ANSWER. `template` falls back to the rag config's\n // `defaultTemplate`, so the call stays this short. `stream: false` is\n // load-bearing: with streaming enabled (the default) this route answers\n // with a channel + resume path for a browser to attach to, NOT the text.\n // A server-side step wants the text, so it says so.\n const answered = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({ query: question, collection: 'kb', top_k: 5, stream: false }),\n });\n if (!answered.ok) {\n const detail = await answered.text();\n return Response.json(\n { error: 'retrieval_failed', status: answered.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const grounded = ((await answered.json()) as {\n data?: { answer?: string; citations?: unknown[]; usage?: unknown };\n }).data ?? {};\n const draft = String(grounded.answer ?? '').trim();\n const citations = Array.isArray(grounded.citations) ? grounded.citations : [];\n if (!draft) return Response.json({ error: 'empty_draft' }, { status: 502 });\n\n // 2. SCORE IT. A forced integer on a fixed scale \u2014 comparable across drafts,\n // unlike \"this looks good\".\n const scored = await fetch(`${base}/v1/ai/judge`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input: question,\n candidate: draft,\n criteria: CRITERIA,\n scale: { min: 0, max: 10 },\n }),\n });\n const verdict = scored.ok\n ? ((await scored.json()) as { data?: { score?: number; verdict?: string; rationale?: string } }).data ?? {}\n : {};\n const score = typeof verdict.score === 'number' ? Math.round(verdict.score) : null;\n\n // 3. PERSIST. A human reads it, edits it, and decides whether it is sent.\n const patch = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { draft, ...(score === null ? {} : { draft_score: score }), state: 'drafted' },\n }),\n });\n\n return Response.json({\n item_id: itemId,\n draft,\n score,\n verdict: verdict.verdict ?? null,\n rationale: verdict.rationale ?? null,\n citations,\n written: patch.ok,\n });\n },\n};\n",
17667
- "triage-ticket.ts": "// triage-ticket.ts \u2014 CLASSIFY EVERY NEW TICKET (a vxil function).\n//\n// Trigger: cmsHook on `tickets`. A hook delivery carries ids, not the row\n// ({ event, collection, item_id }), so the function RE-FETCHES the ticket\n// rather than trusting inline fields \u2014 and delivery is at-least-once, so it\n// skips a ticket that already carries a category instead of re-billing a model\n// call on a redelivery.\n//\n// The one model call is a FORCED-LABEL verdict: `POST /v1/ai/classify` takes the\n// label set and returns exactly one of them (plus a confidence and a one-line\n// rationale). That is the difference between a classifier and a chat prompt \u2014\n// the answer cannot be a paragraph, a new label, or an apology.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\nconst LABELS = ['billing', 'bug', 'how_to', 'other'];\n\ntype Env = CmsHookFunctionEnvelope;\ninterface TicketData { subject?: string; body?: string; category?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (!cms || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ skipped: true, reason: 'not a tickets hook' });\n }\n // The cms.item.* subscription also delivers updates \u2014 only triage a create.\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n const read = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!read.ok) return Response.json({ skipped: true, reason: `fetch ${read.status}` });\n const ticket = ((await read.json()) as { data?: { data?: TicketData } }).data?.data ?? {};\n // Already triaged \u21D2 this is a redelivery. Do nothing (and pay for nothing).\n if (ticket.category) {\n return Response.json({ skipped: true, reason: 'already triaged', category: ticket.category });\n }\n\n const input = `${ticket.subject ?? ''}\\n\\n${ticket.body ?? ''}`.trim();\n if (!input) return Response.json({ skipped: true, reason: 'empty ticket' });\n\n const verdict = await fetch(`${base}/v1/ai/classify`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input,\n labels: LABELS,\n rubric:\n 'billing = money, invoices, refunds or subscriptions. '\n + 'bug = something is broken or behaves incorrectly. '\n + 'how_to = the customer is asking how to do something. '\n + 'other = anything else.',\n }),\n });\n if (!verdict.ok) {\n const detail = await verdict.text();\n return Response.json(\n { error: 'classify_failed', status: verdict.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const v = ((await verdict.json()) as {\n data?: { label?: string; confidence?: number; rationale?: string };\n }).data ?? {};\n const label = LABELS.includes(String(v.label)) ? String(v.label) : 'other';\n\n const patch = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { category: label, state: 'open' } }),\n });\n\n return Response.json({\n item_id: itemId,\n category: label,\n confidence: v.confidence ?? null,\n rationale: v.rationale ?? null,\n written: patch.ok,\n });\n },\n};\n"
17667
+ "draft-reply.ts": "// draft-reply.ts \u2014 RETRIEVE \u2192 GROUND \u2192 SCORE (a vxil function).\n//\n// Trigger: the per-record action `draft_reply` on `tickets`. The action envelope\n// carries the WHOLE row, so this step needs no re-fetch:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// Three calls, three jobs, in order:\n// 1. `POST /v1/rag/answer` \u2014 retrieve from the `kb` index and answer ONLY from\n// what came back, returning the chunks it used as citations. A grounded\n// answer you can audit beats a confident one you cannot.\n// 2. `POST /v1/ai/judge` \u2014 score that draft against a rubric, as an integer on\n// a fixed scale. The model that writes is not the authority on whether the\n// writing is good; a second, schema-forced pass is.\n// 3. one PATCH \u2014 persist the draft + its score so a human decides what to send.\n//\n// Nothing here sends anything to a customer. The last step is always a person.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst CRITERIA = [\n { name: 'answers the question asked', weight: 2 },\n { name: 'is supported by the cited knowledge-base text', weight: 2 },\n { name: 'is concise and free of speculation', weight: 1 },\n];\n\ntype Env = HttpFunctionEnvelope<{\n collection?: string;\n item_id?: string;\n item?: { data?: { subject?: string; body?: string; category?: string } };\n}>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const rag = env.scoped_jwts?.rag;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); this guard stays as belt-and-braces.\n if (!cms || !rag || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ error: 'bad_request', message: 'not a tickets action' }, { status: 400 });\n }\n\n const t = env.payload?.item?.data ?? {};\n const question = `${t.subject ?? ''}\\n\\n${t.body ?? ''}`.trim();\n if (!question) return Response.json({ error: 'empty_ticket' }, { status: 422 });\n\n // 1. GROUNDED ANSWER. `template` falls back to the rag config's\n // `defaultTemplate`, so the call stays this short. `stream: false` is\n // load-bearing: with streaming enabled (the default) this route answers\n // with a channel + resume path for a browser to attach to, NOT the text.\n // A server-side step wants the text, so it says so.\n const answered = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({ query: question, collection: 'kb', top_k: 5, stream: false }),\n });\n if (!answered.ok) {\n const detail = await answered.text();\n return Response.json(\n { error: 'retrieval_failed', status: answered.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const grounded = ((await answered.json()) as {\n data?: { answer?: string; citations?: unknown[]; usage?: unknown };\n }).data ?? {};\n const draft = String(grounded.answer ?? '').trim();\n const citations = Array.isArray(grounded.citations) ? grounded.citations : [];\n if (!draft) return Response.json({ error: 'empty_draft' }, { status: 502 });\n\n // 2. SCORE IT. A forced integer on a fixed scale \u2014 comparable across drafts,\n // unlike \"this looks good\".\n const scored = await fetch(`${base}/v1/ai/judge`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input: question,\n candidate: draft,\n criteria: CRITERIA,\n scale: { min: 0, max: 10 },\n }),\n });\n const verdict = scored.ok\n ? ((await scored.json()) as { data?: { score?: number; verdict?: string; rationale?: string } }).data ?? {}\n : {};\n const score = typeof verdict.score === 'number' ? Math.round(verdict.score) : null;\n\n // 3. PERSIST. A human reads it, edits it, and decides whether it is sent.\n const patch = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { draft, ...(score === null ? {} : { draft_score: score }), state: 'drafted' },\n }),\n });\n\n return Response.json({\n item_id: itemId,\n draft,\n score,\n verdict: verdict.verdict ?? null,\n rationale: verdict.rationale ?? null,\n citations,\n written: patch.ok,\n });\n },\n};\n",
17668
+ "triage-ticket.ts": "// triage-ticket.ts \u2014 CLASSIFY EVERY NEW TICKET (a vxil function).\n//\n// Trigger: cmsHook on `tickets`. A hook delivery carries ids, not the row\n// ({ event, collection, item_id }), so the function RE-FETCHES the ticket\n// rather than trusting inline fields \u2014 and delivery is at-least-once, so it\n// skips a ticket that already carries a category instead of re-billing a model\n// call on a redelivery.\n//\n// The one model call is a FORCED-LABEL verdict: `POST /v1/ai/classify` takes the\n// label set and returns exactly one of them (plus a confidence and a one-line\n// rationale). That is the difference between a classifier and a chat prompt \u2014\n// the answer cannot be a paragraph, a new label, or an apology.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\nconst LABELS = ['billing', 'bug', 'how_to', 'other'];\n\ntype Env = CmsHookFunctionEnvelope;\ninterface TicketData { subject?: string; body?: string; category?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); this guard stays as belt-and-braces.\n if (!cms || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ skipped: true, reason: 'not a tickets hook' });\n }\n // The cms.item.* subscription also delivers updates \u2014 only triage a create.\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n const read = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!read.ok) return Response.json({ skipped: true, reason: `fetch ${read.status}` });\n const ticket = ((await read.json()) as { data?: { data?: TicketData } }).data?.data ?? {};\n // Already triaged \u21D2 this is a redelivery. Do nothing (and pay for nothing).\n if (ticket.category) {\n return Response.json({ skipped: true, reason: 'already triaged', category: ticket.category });\n }\n\n const input = `${ticket.subject ?? ''}\\n\\n${ticket.body ?? ''}`.trim();\n if (!input) return Response.json({ skipped: true, reason: 'empty ticket' });\n\n const verdict = await fetch(`${base}/v1/ai/classify`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input,\n labels: LABELS,\n rubric:\n 'billing = money, invoices, refunds or subscriptions. '\n + 'bug = something is broken or behaves incorrectly. '\n + 'how_to = the customer is asking how to do something. '\n + 'other = anything else.',\n }),\n });\n if (!verdict.ok) {\n const detail = await verdict.text();\n return Response.json(\n { error: 'classify_failed', status: verdict.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const v = ((await verdict.json()) as {\n data?: { label?: string; confidence?: number; rationale?: string };\n }).data ?? {};\n const label = LABELS.includes(String(v.label)) ? String(v.label) : 'other';\n\n const patch = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { category: label, state: 'open' } }),\n });\n\n return Response.json({\n item_id: itemId,\n category: label,\n confidence: v.confidence ?? null,\n rationale: v.rationale ?? null,\n written: patch.ok,\n });\n },\n};\n"
17668
17669
  }
17669
17670
  },
17670
17671
  {
@@ -17685,8 +17686,8 @@ export default defineConfig({
17685
17686
  "byoKeys": [
17686
17687
  "fal_key"
17687
17688
  ],
17688
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"AI Media Studio\" \u2014 images and video from fal.ai, with credits that cannot\n// leak. One function starts a render; vxil does the rest:\n//\n// start-render (your function, end-user mode)\n// \u2192 writes a `renders` row the user owns\n// \u2192 POST /v1/jobs/generation: a fal QUEUE job, credits HELD for it\n// vxil's generation lane\n// \u2192 calls fal with your key, the signed callback in `?fal_webhook=`\n// \u2192 fal POSTs back `{ status: 'OK' | 'ERROR', payload }`\n// \u2192 status_map reads OK/ERROR, result_path picks the images / the video\n// \u2192 the `renders` row gets generation_status + result\n// \u2192 credits COMMIT on success, REFUND on failure or timeout\n//\n// No fal adapter exists in vxil and none is needed: the three generic options\n// (`completion.callback.query_param`, `completion.status_map`,\n// `completion.result_path`) describe any \"POST the job, we call your webhook\"\n// vendor. The platform owns the part a function cannot \u2014 the held credits,\n// the timeout and the refund.\n//\n// The credits half is a payments INTEGRATION on the deterministic `mock`\n// provider \u2014 no provider account needed to try it. \"Credits\" are usage units,\n// not money; vxil is never in the flow of funds.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n generation: {\n maxConcurrent: 10,\n // fal images finish in seconds, an 8 s Veo clip in a few minutes \u2014\n // a run fal never calls back about fails (and refunds) at 15 minutes\n defaultTimeoutMs: 900_000,\n maxTimeoutMs: 1_800_000,\n pollMaxAttempts: 30,\n // a video costs 10 credits; one run may never hold more than 20\n maxReserveCredits: 20,\n // \u2026nor may all of this project's in-flight renders together hold more than 2,000\n maxOutstandingReserveCredits: 2_000,\n },\n },\n\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n productMap: {\n media_pack_100: { creditType: 'media_credits', amount: 100, period: 'once' },\n },\n // subscription tiers: a monthly top-up for subscribers\n tierMap: {\n studio: {\n entitlements: ['render'],\n quotas: {},\n rank: 10,\n grants: [{ creditType: 'media_credits', amount: 300, period: 'monthly' }],\n },\n },\n // a render that fails or times out gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {\n // a render row is live the moment it is written\n draftPublish: false,\n // in end-user mode a collection with no owner is server-only \u2014 renders\n // declares one, so a signed-in user sees only their own renders\n strictEndUserScope: true,\n // Lane-A hook (cms.md \xA77): the dedupe key IS owner + ':' + request_key,\n // server-enforced \u2014 so one user's request_key can never collide with, or\n // block, another user's (a body naming another owner is a 400 anyway).\n hooks: {\n render_dedupe_key: {\n collection: 'renders',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.dedupe_key == concat(item.owner, ':', item.request_key)\",\n message: \"dedupe_key must be owner + ':' + request_key\",\n },\n },\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n renders: {\n singular: 'render',\n ownerField: 'owner',\n fields: {\n // THE DEDUPE ANCHOR \u2014 owner + ':' + the client's own request_key\n // (the hook above enforces the composition), so the key is per user.\n // A retried start (a double tap, a timeout) hits 409 instead of\n // paying twice, and start-render re-drives a start that never got\n // its run.\n dedupe_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n request_key: { type: 'string', required: true },\n owner: { type: 'string', indexSlot: 's2' },\n kind: { type: 'string', required: true, indexSlot: 's3', validation: { enum: ['image', 'video'] } },\n // written by vxil's status mirror: pending \u2192 processing \u2192 completed | failed\n generation_status: { type: 'string', indexSlot: 's4' },\n credits: { type: 'int', indexSlot: 'n1' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n prompt: { type: 'text', required: true },\n run_id: { type: 'text' },\n // written by vxil's status mirror on completion: the value at\n // `result_path` \u2014 fal's `images` list, or its `video` object\n result: { type: 'json' },\n },\n },\n },\n },\n\n functions: {\n // Starts ONE render for the signed-in user. Invoke it in END-USER mode (with\n // the user's session): the held credits are forced onto that user, and the\n // row is theirs.\n 'start-render': {\n entry: './functions/start-render.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'jobs:write'],\n // your fal API key; it rides the provider call's Authorization header\n secrets: ['secret:fal_key'],\n // the function itself never calls fal \u2014 vxil's generation lane does\n egressAllow: [],\n // `vxil gen` types vx.fn['start-render'] from this\n signature: {\n input: { kind: 'string', prompt: 'string', request_key: 'string' },\n output:\n '{ item_id: string; run_id: string; credits: number }'\n + ' | { duplicate: true; request_key: string; item_id: string; run_id: string | null; generation_status: string }',\n },\n },\n },\n\n secrets: {\n fal_key: {\n feature: 'functions',\n description: 'your fal.ai API key (the \"Key \u2026\" credential) \u2014 vxil never holds a fal account of its own',\n },\n },\n});\n",
17689
- "readme": "# AI Media Studio \u2014 fal.ai images and Veo video, with credits that cannot leak\n\n```bash\nvxil init my-studio --template fal-media\ncd my-studio\nprintf '%s' \"$FAL_KEY\" | vxil secrets set functions/fal_key\nvxil push\n```\n\nOne function starts a render; vxil carries it to the end. You bring a fal.ai key; vxil holds the\nuser's credits while fal works, writes the image or the video onto the user's `renders` row when\nfal calls back, and gives the credits back when fal fails or never answers.\n\n**What this blueprint teaches that the others do not:** a third-party *queue* API \u2014 \"POST the job,\nwe call your webhook\" \u2014 completed through the generation lane's **generic completion options**,\nwith no vendor adapter in vxil and no long call held open in a function:\n\n| option | what it does | fal's case |\n|---|---|---|\n| `completion.callback.query_param` | puts vxil's signed callback URL in the provider URL's query string, and sends `provider.body` exactly as written | fal reads its webhook from `?fal_webhook=` and the model input from the body |\n| `completion.status_map` | up to 8 of the provider's own status words \u2192 `completed` \xB7 `failed` \xB7 `processing` | fal's webhook says `OK` or `ERROR` \u2014 words vxil's built-in list would read as \"still working\" until the run timed out |\n| `completion.result_path` | the value the run settles with, written to your record as `result` | `payload.images` (a list of `{ url, width, height, \u2026 }`) or `payload.video` (`{ url, \u2026 }`) |\n\nThe same three options fit any vendor that calls you back (a transcode API, a render farm, a\nlong-running inference host) \u2014 change the URL, the words and the path.\n\n## What you get\n\n- **`renders`** \u2014 one row per request, owned by the user who started it (`strictEndUserScope`, so a\n signed-in user reads only their own renders). `dedupe_key` (the owner + `:` + the client's\n `request_key`, the composition enforced by a `beforeWrite` hook) is unique, so keys are **per user**: a\n double tap or a retried start is a `409` the function reads back. A row that already has its run is a\n duplicate; a row with no run (the first start died, or lost its enqueue answer, between the row and\n the enqueue) is **re-driven** \u2014 the same `dedupe_key` is the generation run's `idempotency_key`, so\n jobs hands back the existing run and fal is never asked twice.\n- **`start-render`** (http function, end-user mode) \u2014 writes the row, then enqueues ONE generation run:\n fal's queue URL, your key in `Authorization: Key \u2026`, the three options above, a status mirror onto\n the row, and `reserve_credits` for the render's cost (image 1, video 10 `media_credits`).\n- **credits** \u2014 a payments integration on the `mock` provider (no provider account needed). The hold is\n taken *before* the run is queued; a user without the credits gets `402` at once and nothing is held.\n Success commits the hold; a fal `ERROR`, a run fal never calls back about (the timeout: 5 min for an\n image, 15 for a video) or a cancel refunds it (`ledger.autoRefundOnJobFailure`).\n\n## Run it\n\nGive a user some credits from your server (or sell `media_pack_100` through your payments provider):\n\n```bash\ncurl -s -X POST \"https://api.vxil.com/v1/payments/credits/grant\" \\\n -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n -H 'idempotency-key: welcome-u1' \\\n -d '{\"user_id\":\"<the user id>\",\"credit_type\":\"media_credits\",\"amount\":25,\"source\":\"welcome\"}'\n```\n\nStart a render **with the user's session** (end-user mode \u2014 the held credits are forced onto that\nuser, and a function cannot hold credits for anyone else):\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// after `vxil gen`, vx.fn['start-render'] is typed from the function's declared signature\nconst vx = new Vxil({ apiKey: process.env.VXIL_PUBLISHABLE_KEY!, endUserToken: process.env.USER_SESSION! });\nconst started = await vx.fn['start-render']({ kind: 'image', prompt: 'a red fox in the snow', request_key: 'fox-1' });\n// \u2192 { item_id, run_id, credits: 1 }\n// (or { duplicate: true, request_key, item_id, run_id, generation_status } on a retry)\n```\n\nThen read the row \u2014 or subscribe to its changes \u2014 until `generation_status` is `completed`:\n\n```ts\nif ('item_id' in started) {\n const row = await vx.from('renders').get(started.item_id);\n // row.generation_status \u2192 'completed'\n // row.result \u2192 [{ url: 'https://fal.media/files/\u2026png', width: 1024, height: 1024, \u2026 }]\n}\n```\n\nEvery run ends with `job.generation.completed` or `job.generation.failed` (`generation_id` = the row's\n`item_id`, `correlation_id` = its `request_key`), so a function bound to `job.generation.` can react \u2014\nsend a push, thumbnail the image \u2014 without a lookup table of its own.\n\n## How it fails, and what the user sees\n\n| what happened | the run | the row | the credits |\n|---|---|---|---|\n| fal answered `OK` | `completed` | `generation_status: completed`, `result` set | committed |\n| fal answered `ERROR` (its `error` text is the run's `last_error_msg`; `job.generation.failed` carries `error_class: 'ProviderFailed'`) | `failed` | `generation_status: failed` | refunded |\n| fal never called back | `failed` (`GenerationExpired`) at the timeout | `failed` | refunded |\n| the start call to fal failed with a 5xx / network fault | retried with backoff; terminal after the attempts | `processing` \u2192 the outcome above | held until then |\n| the user had too few credits | ended at once (`ReserveInsufficient`), never queued | `failed` (the function marks it); that `request_key` is spent \u2014 retry after a top-up with a new one | nothing held |\n| too many renders in flight, or a jobs-side fault | not created (the function answers the caller `429` with the wait in `retry_after`, or `502`) | `pending`, no run \u2014 calling start-render again with the **same** `request_key` re-drives it | nothing held |\n\n## Evidence\n\n- **Executed** (vxil's own int tests, `workers/jobs-v1/src/generation-completion-options.int.test.ts`):\n the `?fal_webhook=` callback URL is signed and verifies, the provider receives exactly the model input,\n `OK` settles completed with `payload.images` mirrored as `result` and the hold committed, `ERROR`\n settles failed with the hold refunded, a progress ping stays processing.\n- **Read in fal's documentation, not executed against fal from vxil** \u2014 the queue base\n `https://queue.fal.run/<model>`, the `fal_webhook` query parameter, the webhook body\n `{ request_id, status: 'OK' | 'ERROR', payload, error }`, and the model ids `fal-ai/flux/dev` and\n `fal-ai/veo3` with their inputs. Check the model page for each model's exact input fields before you\n ship, and keep `num_images` / `duration` to what your model accepts.\n\n## Your key, and where it lives\n\n`fal_key` is a function secret; `start-render` reads it at invoke time and puts it in the generation\nrun's provider headers, which vxil stores with the run (your project only) for as long as the jobs\nretention keeps the run, and sends to fal on the start call. It is **never returned by a read**:\n`GET /v1/jobs/runs/{run_id}` (and the dashboard and MCP reads built on it) shows the header names of a\ngeneration run with every value as `[redacted]`. Rotate it with `vxil secrets set functions/fal_key`;\nruns already queued keep the key they were started with.\n",
17689
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"AI Media Studio\" \u2014 images and video from fal.ai, with credits that cannot\n// leak. One function starts a render; vxil does the rest:\n//\n// start-render (your function, end-user mode)\n// \u2192 writes a `renders` row the user owns\n// \u2192 POST /v1/jobs/generation: a fal QUEUE job, credits HELD for it\n// vxil's generation lane\n// \u2192 calls fal with your key, the signed callback in `?fal_webhook=`\n// \u2192 fal POSTs back `{ status: 'OK' | 'ERROR', payload }`\n// \u2192 status_map reads OK/ERROR, result_path picks the images / the video\n// \u2192 the `renders` row gets generation_status + result\n// \u2192 credits COMMIT on success, REFUND on failure or timeout\n//\n// No fal adapter exists in vxil and none is needed: the three generic options\n// (`completion.callback.query_param`, `completion.status_map`,\n// `completion.result_path`) describe any \"POST the job, we call your webhook\"\n// vendor. The platform owns the part a function cannot \u2014 the held credits,\n// the timeout and the refund.\n//\n// The credits half is a payments INTEGRATION on the deterministic `mock`\n// provider \u2014 no provider account needed to try it. \"Credits\" are usage units,\n// not money; vxil is never in the flow of funds.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n generation: {\n maxConcurrent: 10,\n // fal images finish in seconds, an 8 s Veo clip in a few minutes \u2014\n // a run fal never calls back about fails (and refunds) at 15 minutes\n defaultTimeoutMs: 900_000,\n maxTimeoutMs: 1_800_000,\n pollMaxAttempts: 30,\n // a video costs 10 credits; one run may never hold more than 20\n maxReserveCredits: 20,\n // \u2026nor may all of this project's in-flight renders together hold more than 2,000\n maxOutstandingReserveCredits: 2_000,\n },\n },\n\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n productMap: {\n media_pack_100: { creditType: 'media_credits', amount: 100, period: 'once' },\n },\n // subscription tiers: a monthly top-up for subscribers\n tierMap: {\n studio: {\n entitlements: ['render'],\n quotas: {},\n rank: 10,\n grants: [{ creditType: 'media_credits', amount: 300, period: 'monthly' }],\n },\n },\n // a render that fails or times out gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {\n // a render row is live the moment it is written\n draftPublish: false,\n // in end-user mode a collection with no owner is server-only \u2014 renders\n // declares one, so a signed-in user sees only their own renders\n strictEndUserScope: true,\n // Lane-A hook (guide ch. 7): the dedupe key IS owner + ':' + request_key,\n // server-enforced \u2014 so one user's request_key can never collide with, or\n // block, another user's (a body naming another owner is a 400 anyway).\n hooks: {\n render_dedupe_key: {\n collection: 'renders',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.dedupe_key == concat(item.owner, ':', item.request_key)\",\n message: \"dedupe_key must be owner + ':' + request_key\",\n },\n },\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n renders: {\n singular: 'render',\n ownerField: 'owner',\n fields: {\n // THE DEDUPE ANCHOR \u2014 owner + ':' + the client's own request_key\n // (the hook above enforces the composition), so the key is per user.\n // A retried start (a double tap, a timeout) hits 409 instead of\n // paying twice, and start-render re-drives a start that never got\n // its run.\n dedupe_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n request_key: { type: 'string', required: true },\n owner: { type: 'string', indexSlot: 's2' },\n kind: { type: 'string', required: true, indexSlot: 's3', validation: { enum: ['image', 'video'] } },\n // written by vxil's status mirror: pending \u2192 processing \u2192 completed | failed\n generation_status: { type: 'string', indexSlot: 's4' },\n credits: { type: 'int', indexSlot: 'n1' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n prompt: { type: 'text', required: true },\n run_id: { type: 'text' },\n // written by vxil's status mirror on completion: the value at\n // `result_path` \u2014 fal's `images` list, or its `video` object\n result: { type: 'json' },\n },\n },\n },\n },\n\n functions: {\n // Starts ONE render for the signed-in user. Invoke it in END-USER mode (with\n // the user's session): the held credits are forced onto that user, and the\n // row is theirs.\n 'start-render': {\n entry: './functions/start-render.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'jobs:write'],\n // your fal API key; it rides the provider call's Authorization header\n secrets: ['secret:fal_key'],\n // the function itself never calls fal \u2014 vxil's generation lane does\n egressAllow: [],\n // `vxil gen` types vx.fn['start-render'] from this\n signature: {\n input: { kind: 'string', prompt: 'string', request_key: 'string' },\n output:\n '{ item_id: string; run_id: string; credits: number }'\n + ' | { duplicate: true; request_key: string; item_id: string; run_id: string | null; generation_status: string }',\n },\n },\n },\n\n secrets: {\n fal_key: {\n feature: 'functions',\n description: 'your fal.ai API key (the \"Key \u2026\" credential) \u2014 vxil never holds a fal account of its own',\n },\n },\n});\n",
17690
+ "readme": "# AI Media Studio \u2014 fal.ai images and Veo video, with credits that cannot leak\n\n```bash\nvxil init my-studio --template fal-media\ncd my-studio\nprintf '%s' \"$FAL_KEY\" | vxil secrets set functions/fal_key\nvxil push\n```\n\n> **Plan note.** The function deploys on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\nOne function starts a render; vxil carries it to the end. You bring a fal.ai key; vxil holds the\nuser's credits while fal works, writes the image or the video onto the user's `renders` row when\nfal calls back, and gives the credits back when fal fails or never answers.\n\n**What this blueprint teaches that the others do not:** a third-party *queue* API \u2014 \"POST the job,\nwe call your webhook\" \u2014 completed through the generation lane's **generic completion options**,\nwith no vendor adapter in vxil and no long call held open in a function:\n\n| option | what it does | fal's case |\n|---|---|---|\n| `completion.callback.query_param` | puts vxil's signed callback URL in the provider URL's query string, and sends `provider.body` exactly as written | fal reads its webhook from `?fal_webhook=` and the model input from the body |\n| `completion.status_map` | up to 8 of the provider's own status words \u2192 `completed` \xB7 `failed` \xB7 `processing` | fal's webhook says `OK` or `ERROR` \u2014 words vxil's built-in list would read as \"still working\" until the run timed out |\n| `completion.result_path` | the value the run settles with, written to your record as `result` | `payload.images` (a list of `{ url, width, height, \u2026 }`) or `payload.video` (`{ url, \u2026 }`) |\n\nThe same three options fit any vendor that calls you back (a transcode API, a render farm, a\nlong-running inference host) \u2014 change the URL, the words and the path.\n\n## What you get\n\n- **`renders`** \u2014 one row per request, owned by the user who started it (`strictEndUserScope`, so a\n signed-in user reads only their own renders). `dedupe_key` (the owner + `:` + the client's\n `request_key`, the composition enforced by a `beforeWrite` hook) is unique, so keys are **per user**: a\n double tap or a retried start is a `409` the function reads back. A row that already has its run is a\n duplicate; a row with no run (the first start died, or lost its enqueue answer, between the row and\n the enqueue) is **re-driven** \u2014 the same `dedupe_key` is the generation run's `idempotency_key`, so\n jobs hands back the existing run and fal is never asked twice.\n- **`start-render`** (http function, end-user mode) \u2014 writes the row, then enqueues ONE generation run:\n fal's queue URL, your key in `Authorization: Key \u2026`, the three options above, a status mirror onto\n the row, and `reserve_credits` for the render's cost (image 1, video 10 `media_credits`).\n- **credits** \u2014 a payments integration on the `mock` provider (no provider account needed). The hold is\n taken *before* the run is queued; a user without the credits gets `402` at once and nothing is held.\n Success commits the hold; a fal `ERROR`, a run fal never calls back about (the timeout: 5 min for an\n image, 15 for a video) or a cancel refunds it (`ledger.autoRefundOnJobFailure`).\n\n## Run it\n\nGive a user some credits from your server (or sell `media_pack_100` through your payments provider):\n\n```bash\ncurl -s -X POST \"https://api.vxil.com/v1/payments/credits/grant\" \\\n -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n -H 'idempotency-key: welcome-u1' \\\n -d '{\"user_id\":\"<the user id>\",\"credit_type\":\"media_credits\",\"amount\":25,\"source\":\"welcome\"}'\n```\n\nStart a render **with the user's session** (end-user mode \u2014 the held credits are forced onto that\nuser, and a function cannot hold credits for anyone else):\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// after `vxil gen`, vx.fn['start-render'] is typed from the function's declared signature\nconst vx = new Vxil({ apiKey: process.env.VXIL_PUBLISHABLE_KEY!, endUserToken: process.env.USER_SESSION! });\nconst started = await vx.fn['start-render']({ kind: 'image', prompt: 'a red fox in the snow', request_key: 'fox-1' });\n// \u2192 { item_id, run_id, credits: 1 }\n// (or { duplicate: true, request_key, item_id, run_id, generation_status } on a retry)\n```\n\nThen read the row \u2014 or subscribe to its changes \u2014 until `generation_status` is `completed`:\n\n```ts\nif ('item_id' in started) {\n const row = await vx.from('renders').get(started.item_id);\n // row.generation_status \u2192 'completed'\n // row.result \u2192 [{ url: 'https://fal.media/files/\u2026png', width: 1024, height: 1024, \u2026 }]\n}\n```\n\nEvery run ends with `job.generation.completed` or `job.generation.failed` (`generation_id` = the row's\n`item_id`, `correlation_id` = its `request_key`), so a function bound to `job.generation.` can react \u2014\nsend a push, thumbnail the image \u2014 without a lookup table of its own.\n\n## How it fails, and what the user sees\n\n| what happened | the run | the row | the credits |\n|---|---|---|---|\n| fal answered `OK` | `completed` | `generation_status: completed`, `result` set | committed |\n| fal answered `ERROR` (its `error` text is the run's `last_error_msg`; `job.generation.failed` carries `error_class: 'ProviderFailed'`) | `failed` | `generation_status: failed` | refunded |\n| fal never called back | `failed` (`GenerationExpired`) at the timeout | `failed` | refunded |\n| the start call to fal failed with a 5xx / network fault | retried with backoff; terminal after the attempts | `processing` \u2192 the outcome above | held until then |\n| the user had too few credits | ended at once (`ReserveInsufficient`), never queued | `failed` (the function marks it); that `request_key` is spent \u2014 retry after a top-up with a new one | nothing held |\n| too many renders in flight, or a jobs-side fault | not created (the function answers the caller `429` with the wait in `retry_after`, or `502`) | `pending`, no run \u2014 calling start-render again with the **same** `request_key` re-drives it | nothing held |\n\n## Evidence\n\n- **Executed** (vxil's own integration tests):\n the `?fal_webhook=` callback URL is signed and verifies, the provider receives exactly the model input,\n `OK` settles completed with `payload.images` mirrored as `result` and the hold committed, `ERROR`\n settles failed with the hold refunded, a progress ping stays processing.\n- **Read in fal's documentation, not executed against fal from vxil** \u2014 the queue base\n `https://queue.fal.run/<model>`, the `fal_webhook` query parameter, the webhook body\n `{ request_id, status: 'OK' | 'ERROR', payload, error }`, and the model ids `fal-ai/flux/dev` and\n `fal-ai/veo3` with their inputs. Check the model page for each model's exact input fields before you\n ship, and keep `num_images` / `duration` to what your model accepts.\n\n## Your key, and where it lives\n\n`fal_key` is a function secret; `start-render` reads it at invoke time and puts it in the generation\nrun's provider headers, which vxil stores with the run (your project only) for as long as the jobs\nretention keeps the run, and sends to fal on the start call. It is **never returned by a read**:\n`GET /v1/jobs/runs/{run_id}` (and the dashboard and MCP reads built on it) shows the header names of a\ngeneration run with every value as `[redacted]`. Rotate it with `vxil secrets set functions/fal_key`;\nruns already queued keep the key they were started with.\n",
17690
17691
  "functions": {
17691
17692
  "start-render.ts": "// start-render.ts \u2014 start ONE fal.ai render for the signed-in user (a vxil\n// function, http trigger, END-USER mode).\n//\n// POST /v1/fn/start-render (with the user's session)\n// { \"kind\": \"image\" | \"video\", \"prompt\": \"\u2026\", \"request_key\": \"<your idempotency key>\" }\n// \u2192 202 { item_id, run_id, credits } a new render, credits held\n// \u2192 200 { duplicate: true, request_key, item_id, run_id, generation_status }\n// this user's request_key already started one\n//\n// What happens after the 202 is vxil's, not this function's:\n// \u2022 the generation lane POSTs fal's QUEUE API with your key and the signed\n// callback URL in `?fal_webhook=` (completion.callback.query_param);\n// \u2022 fal calls back `{ status: 'OK' | 'ERROR', payload, error? }` \u2014 status_map\n// turns its words into completed / failed;\n// \u2022 result_path picks `payload.images` (or `payload.video`), and the status\n// mirror writes it onto this render's `result` with generation_status;\n// \u2022 the held credits commit on completed and are REFUNDED on failed, on a\n// run fal never calls back about (the timeout), and on a cancel.\n// Every run ends with job.generation.completed or job.generation.failed\n// (generation_id = the render's item_id, correlation_id = its request_key) if\n// you want to react to it in another function.\n//\n// DELIVERY IS AT-LEAST-ONCE and users double-tap: the row's `dedupe_key`\n// (owner + ':' + request_key \u2014 PER USER, so one user's key can never block\n// another's) is declared unique, so a second start with the same key is a\n// 409. On a 409 we read THIS user's row: a row that already has its run is a\n// duplicate; a row with no run (the first start died, or its enqueue answer\n// was lost, between the row and the enqueue) is RE-DRIVEN \u2014 the generation\n// run carries the same dedupe_key as its idempotency_key, so jobs hands back\n// the existing run instead of enqueueing a second fal job.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Input = { kind?: string; prompt?: string; request_key?: string };\ntype Env = HttpFunctionEnvelope<Input>;\ntype RenderRow = { item_id: string; data: { kind?: string; prompt?: string; run_id?: string; generation_status?: string } };\n\n/** fal model ids (https://fal.ai/models) \u2014 swap freely; both are queue APIs. */\nconst MODELS = {\n image: { url: 'https://queue.fal.run/fal-ai/flux/dev', credits: 1, resultPath: 'payload.images' },\n video: { url: 'https://queue.fal.run/fal-ai/veo3', credits: 10, resultPath: 'payload.video' },\n} as const;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const jobs = env.scoped_jwts?.jobs;\n const falKey = env.secrets?.fal_key;\n if (!cms || !jobs) return Response.json({ error: 'missing cms/jobs scope' }, { status: 403 });\n if (!falKey) return Response.json({ error: 'store your fal key: vxil secrets set functions/fal_key' }, { status: 500 });\n // the held credits are FORCED onto the verified end-user \u2014 a function\n // cannot hold credits against a user it does not act for\n const user = env.end_user?.id;\n if (!user) return Response.json({ error: 'invoke start-render with the user\\'s session (end-user mode)' }, { status: 401 });\n\n const kind = env.payload?.kind === 'video' ? 'video' : env.payload?.kind === 'image' ? 'image' : null;\n const prompt = typeof env.payload?.prompt === 'string' ? env.payload.prompt.trim().slice(0, 2_000) : '';\n const requestKey = typeof env.payload?.request_key === 'string' ? env.payload.request_key.slice(0, 120) : '';\n if (!kind || !prompt || !requestKey) {\n return Response.json({ error: 'need { kind: image|video, prompt, request_key }' }, { status: 422 });\n }\n const dedupeKey = `${user}:${requestKey}`;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n // 1. the render row (owned by the user \u2014 cms forces `owner` in end-user mode;\n // the render_dedupe_key hook checks dedupe_key = owner + ':' + request_key)\n const created = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: H,\n body: JSON.stringify({\n data: {\n dedupe_key: dedupeKey, request_key: requestKey, owner: user, kind, prompt,\n credits: MODELS[kind].credits, generation_status: 'pending', created_at: new Date().toISOString(),\n },\n }),\n });\n if (created.status === 409) {\n // THIS user's row for this key (the read is owner-scoped in end-user mode)\n const filter = encodeURIComponent(JSON.stringify({ dedupe_key: dedupeKey }));\n const found = await fetch(`${base}/v1/cms/items/renders?filter=${filter}&limit=1`, { headers: H });\n const row = found.ok ? ((await found.json()) as { data?: { items?: RenderRow[] } }).data?.items?.[0] : undefined;\n if (!row) return Response.json({ error: `render lookup: ${found.status}` }, { status: 502 });\n if (row.data.run_id || row.data.generation_status === 'failed') {\n return Response.json({\n duplicate: true, request_key: requestKey, item_id: row.item_id,\n run_id: row.data.run_id ?? null, generation_status: row.data.generation_status ?? 'pending',\n });\n }\n // a start that never got its run: re-drive it (jobs dedupes on dedupe_key)\n const rowKind = row.data.kind === 'video' ? 'video' : 'image';\n return startRun(base, H, jobs, falKey, user, dedupeKey, requestKey, row.item_id, rowKind, row.data.prompt ?? prompt);\n }\n if (!created.ok) return Response.json({ error: `render row: ${created.status}` }, { status: 502 });\n const itemId = ((await created.json()) as { data: { item_id: string } }).data.item_id;\n return startRun(base, H, jobs, falKey, user, dedupeKey, requestKey, itemId, kind, prompt);\n },\n};\n\n/** 2. the fal queue job, babysat by vxil, credits held for it. Idempotent on\n * `dedupeKey`: a re-drive gets the run that already exists. */\nasync function startRun(\n base: string, H: Record<string, string>, jobs: string, falKey: string, user: string,\n dedupeKey: string, requestKey: string, itemId: string, kind: 'image' | 'video', prompt: string,\n): Promise<Response> {\n const model = MODELS[kind];\n const enq = await fetch(`${base}/v1/jobs/generation`, {\n method: 'POST',\n headers: { authorization: `Bearer ${jobs}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: `fal.${kind}`,\n // the body is EXACTLY fal's model input (query-string callbacks send\n // provider.body as-is \u2014 no payload / callback_url keys merged in)\n provider: {\n url: model.url,\n method: 'POST',\n headers: { authorization: `Key ${falKey}` },\n body: kind === 'image' ? { prompt, num_images: 1 } : { prompt, duration: '8s' },\n },\n completion: {\n mode: 'webhook',\n status_path: 'status',\n callback: { query_param: 'fal_webhook' },\n status_map: { OK: 'completed', ERROR: 'failed' },\n result_path: model.resultPath,\n },\n status_mirror: { feature: 'cms', collection: 'renders', record_id: itemId, column: 'generation_status' },\n reserve_credits: { amount: model.credits, user_id: user, credit_type: 'media_credits', reason: `fal ${kind}` },\n timeout: { after_ms: kind === 'image' ? 300_000 : 900_000 },\n // rides job.generation.* as generation_id / correlation_id\n payload: { generation_id: itemId, correlation_id: requestKey },\n idempotency_key: dedupeKey,\n }),\n });\n if (enq.status === 402) {\n // not enough credits: the run already ENDED (job.generation.failed,\n // ReserveInsufficient) and nothing was held \u2014 mark the row and say so.\n // This key is spent; a new attempt (after a top-up) uses a new request_key.\n await markFailed(base, H, itemId);\n return Response.json({ error: 'insufficient_credits', item_id: itemId }, { status: 402 });\n }\n if (!enq.ok) {\n // 429 (too many in flight) / 5xx: NO run is promised \u2014 leave the row\n // pending with no run, so a retry with the SAME request_key re-drives it\n return Response.json(\n { error: `generation enqueue: ${enq.status}`, item_id: itemId, retry_after: enq.headers.get('retry-after') },\n { status: enq.status === 429 ? 429 : 502 },\n );\n }\n const runId = ((await enq.json()) as { data: { run_id: string } }).data.run_id;\n await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify({ data: { run_id: runId } }),\n });\n return Response.json({ item_id: itemId, run_id: runId, credits: model.credits }, { status: 202 });\n}\n\nasync function markFailed(base: string, H: Record<string, string>, itemId: string): Promise<void> {\n await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify({ data: { generation_status: 'failed' } }),\n });\n}\n"
17692
17693
  }
@@ -17718,250 +17719,13 @@ export default defineConfig({
17718
17719
  "stripe_secret",
17719
17720
  "stripe_webhook"
17720
17721
  ],
17721
- "configSrc": `import { defineConfig } from '@vxil/config';
17722
-
17723
- // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
17724
- // "Storefront" \u2014 a single-seller e-commerce backend, declared end-to-end in ONE
17725
- // typed file + four functions. There is no vxil "e-commerce feature": this is a
17726
- // BLUEPRINT composing shipped building blocks \u2014
17727
- // \u2022 cms \u2192 the relational spine (catalog, variants, carts, orders, coupons, reviews)
17728
- // \u2022 payments \u2192 a payments INTEGRATION with your own Stripe account (checkout
17729
- // sessions + a store-credit ledger; vxil is never in the flow of funds)
17730
- // \u2022 auth \u2192 shopper accounts, incl. anonymous GUEST checkout
17731
- // \u2022 functions \u2192 the domain logic that isn't config: the checkout SAGA + pricing engine
17732
- // \u2022 notifications \u2192 order receipts + abandoned-cart nudges
17733
- // The fully-annotated deep version of this blueprint lives in examples/ecommerce.
17734
- // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
17735
- export default defineConfig({
17736
- env: 'staging',
17737
-
17738
- features: {
17739
- cms: {
17740
- draftPublish: true, // published rows are the live catalog; drafts are staging
17741
- // Fail-safe: a verified end-user may only touch collections that declare an
17742
- // ownerField (carts/orders/reviews) \u2014 never a tenant-wide collection.
17743
- strictEndUserScope: true,
17744
- hooks: {
17745
- // \u2605 THE ORDER STATE MACHINE: reject any illegal status transition, atomically,
17746
- // in the same write tx. (Cross-row work \u2014 the checkout saga \u2014 is a function.)
17747
- order_transition: {
17748
- collection: 'orders',
17749
- event: 'beforeUpdate',
17750
- kind: 'validate',
17751
- expr:
17752
- "item.status == before.status" +
17753
- " || (before.status == 'pending' && (item.status == 'paid' || item.status == 'cancelled'))" +
17754
- " || (before.status == 'paid' && (item.status == 'fulfilled' || item.status == 'refunded'))" +
17755
- " || (before.status == 'fulfilled' && item.status == 'shipped')",
17756
- message: 'illegal order status transition',
17757
- },
17758
- // A review must carry a 1\u20135 rating.
17759
- review_rating: {
17760
- collection: 'reviews',
17761
- event: 'beforeWrite',
17762
- kind: 'validate',
17763
- expr: 'item.rating >= 1 && item.rating <= 5',
17764
- message: 'rating must be 1\u20135',
17765
- },
17766
- },
17767
- },
17768
-
17769
- auth: {
17770
- methods: { emailPassword: true, magicLink: true },
17771
- anonymous: { enabled: true }, // guest checkout \u2192 claim the email later
17772
- },
17773
-
17774
- payments: {
17775
- provider: 'stripe',
17776
- stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },
17777
- defaults: { currency: 'usd' },
17778
- // The ledger is for STORE CREDIT / gift cards / loyalty (add-only grants +
17779
- // balance-guarded consume) \u2014 populate productMap/tierMap per vxil.com/docs/guide/06-feature-catalog (payments).
17780
- ledger: { productMap: {}, tierMap: {} },
17781
- },
17782
-
17783
- notifications: { provider: 'mock', fromEmail: 'orders@storefront.example' },
17784
-
17785
- functions: { enabled: true },
17786
- },
17787
-
17788
- // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500
17789
- cms: {
17790
- collections: {
17791
- products: {
17792
- singular: 'product',
17793
- // PUBLIC DELIVERY (cms.md \xA716): the SHOPFRONT \u2014 published products read with
17794
- // NO API key over GET /v1/cms/public/:tenantId/products, edge-cached. This is
17795
- // the anonymous browse tier; the transactional collections below (carts,
17796
- // orders, coupons) are NOT public and stay behind the owner-scoped authed lane.
17797
- public: true,
17798
- fields: {
17799
- title: { type: 'string', required: true, indexSlot: 's1' },
17800
- slug: { type: 'string', required: true, indexSlot: 's2', unique: true },
17801
- category: { type: 'string', indexSlot: 's3' },
17802
- brand: { type: 'string', indexSlot: 's4' },
17803
- price_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },
17804
- rating_avg: { type: 'float', indexSlot: 'n2' }, // derived from reviews
17805
- description: { type: 'text' },
17806
- images: { type: 'json' },
17807
- },
17808
- },
17809
- variants: {
17810
- singular: 'variant',
17811
- // PUBLIC DELIVERY (cms.md \xA716): the product-detail page needs the sellable
17812
- // variants (price, availability, provider price_ref) \u2014 all shopfront-public
17813
- // by design. \`stock\` here is a public availability signal; the oversell-safe
17814
- // DECREMENT still happens only on the authed write lane inside checkout.ts.
17815
- public: true,
17816
- fields: {
17817
- product: { type: 'relation', relationTo: 'products' },
17818
- sku: { type: 'string', required: true, indexSlot: 's1', unique: true },
17819
- title: { type: 'string', indexSlot: 's2' },
17820
- price_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },
17821
- // \u2605 THE OVERSELL-SAFE STOCK PATTERN: validation.min:0 makes a \`$inc{stock:-qty}\`
17822
- // a single-statement conditional decrement (WHERE stock-qty >= 0) \u2014 exactly one
17823
- // 200 under N concurrent buyers, 409 inc_out_of_bounds otherwise. No oversell.
17824
- stock: { type: 'int', indexSlot: 'n2', validation: { min: 0 } },
17825
- // The PROVIDER's price id for this variant (e.g. a Stripe Price) \u2014 the hosted
17826
- // checkout-session charges by provider price ref, so mirror each sellable
17827
- // variant into your Stripe catalog and store its id here (BYO provider).
17828
- price_ref: { type: 'string' },
17829
- options: { type: 'json' }, // { size, color, \u2026 } \u2014 not indexed
17830
- },
17831
- },
17832
- carts: {
17833
- singular: 'cart',
17834
- ownerField: 'end_user', // owner-scoped: a shopper (or guest session) sees only their own carts
17835
- fields: {
17836
- status: { type: 'string', indexSlot: 's1' }, // open | ordered | abandoned
17837
- currency: { type: 'string', indexSlot: 's2' },
17838
- end_user: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id
17839
- last_activity: { type: 'datetime', indexSlot: 't1' }, // abandoned-cart cron filter
17840
- },
17841
- },
17842
- cart_items: {
17843
- singular: 'cart_item',
17844
- fields: {
17845
- cart: { type: 'relation', relationTo: 'carts' },
17846
- variant: { type: 'relation', relationTo: 'variants' },
17847
- qty: { type: 'int', indexSlot: 'n1', validation: { min: 1 } },
17848
- unit_price_cents: { type: 'int', indexSlot: 'n2' }, // price snapshot at add-to-cart
17849
- // provider price-id snapshot at add-to-cart (from variants.price_ref) \u2014
17850
- // checkout's line_items charge by THIS ref (payments checkout-sessions contract).
17851
- price_ref: { type: 'string', required: true },
17852
- },
17853
- },
17854
- orders: {
17855
- singular: 'order',
17856
- ownerField: 'end_user',
17857
- fields: {
17858
- number: { type: 'string', required: true, indexSlot: 's1', unique: true },
17859
- // \u2605 EXACTLY-ONCE PLACEMENT: unique cart_ref \u2014 N racing checkouts of the same
17860
- // cart \u2192 exactly one order insert succeeds (409 on the rest = idempotent).
17861
- cart_ref: { type: 'string', required: true, indexSlot: 's2', unique: true },
17862
- status: { type: 'string', indexSlot: 's3' }, // pending|paid|fulfilled|shipped|refunded|cancelled
17863
- end_user: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id
17864
- total_cents: { type: 'int', indexSlot: 'n1' },
17865
- placed_at: { type: 'datetime', indexSlot: 't1' },
17866
- lines: { type: 'json' }, // [{ variant, qty, unit_price_cents, price_ref }] snapshot
17867
- },
17868
- },
17869
- coupons: {
17870
- singular: 'coupon',
17871
- fields: {
17872
- code: { type: 'string', required: true, indexSlot: 's1', unique: true },
17873
- kind: { type: 'string', indexSlot: 's2' }, // percent | fixed
17874
- value: { type: 'int', indexSlot: 'n1' }, // percent (1\u2013100) or cents
17875
- max_uses: { type: 'int', indexSlot: 'n2' },
17876
- },
17877
- },
17878
- // \u2605 One row per redemption. When you honor a coupon at capture, write the row
17879
- // with a \`guards[]\` count-cap ON THE WRITE BODY \u2014 { lock: 'coupon:'+code,
17880
- // guards: [{ filter: { code }, max: max_uses }] } (vxil.com/docs/api;
17881
- // guard/guards ride the request, they are NOT config) \u2014 so a coupon can never
17882
- // over-redeem under concurrency. (Not wired into checkout.ts \u2014 add it there.)
17883
- coupon_redemptions: {
17884
- singular: 'coupon_redemption',
17885
- fields: {
17886
- code: { type: 'string', required: true, indexSlot: 's1' },
17887
- order: { type: 'relation', relationTo: 'orders' },
17888
- },
17889
- },
17890
- // Owner-scoped write (a verified shopper posts their own), moderated via draft/publish.
17891
- reviews: {
17892
- singular: 'review',
17893
- ownerField: 'end_user',
17894
- fields: {
17895
- product: { type: 'relation', relationTo: 'products' },
17896
- rating: { type: 'int', required: true, indexSlot: 'n1', validation: { min: 1, max: 5 } },
17897
- title: { type: 'string', indexSlot: 's1' },
17898
- end_user: { type: 'string', indexSlot: 's2' }, // the owner (end-user) id
17899
- body: { type: 'text' },
17900
- },
17901
- },
17902
- },
17903
- },
17904
-
17905
- // \u2500\u2500 The domain logic that ISN'T config: tenant functions \u2500\u2500
17906
- functions: {
17907
- // The checkout SAGA \u2014 reserve inventory + create order + open payment, all-or-nothing.
17908
- // Invoke checkout/price-cart SERVER-SIDE (your backend, server key): with
17909
- // strictEndUserScope on, an end-user-mode invocation is correctly denied on the
17910
- // shared collections they touch (cart_items/variants/coupons).
17911
- checkout: {
17912
- entry: './functions/checkout.ts',
17913
- trigger: { kind: 'http' },
17914
- scopes: ['cms:read', 'cms:write', 'payments:write'],
17915
- egressAllow: [], // pure vxil-internal; no external egress needed
17916
- },
17917
- // The pricing ENGINE \u2014 full JS (stacking/BOGO/tiers/coupons); cross-row cart math
17918
- // is forbidden in hooks by design. Recompute server-side = anti-tamper.
17919
- 'price-cart': {
17920
- entry: './functions/price-cart.ts',
17921
- trigger: { kind: 'http' },
17922
- scopes: ['cms:read'],
17923
- },
17924
- // Settlement: on order \u2192 paid, fire the receipt + fulfillment webhook (side-effects).
17925
- 'on-order-paid': {
17926
- entry: './functions/on-order-paid.ts',
17927
- trigger: { kind: 'cmsHook', collection: 'orders', event: 'beforeUpdate' },
17928
- scopes: ['cms:read', 'notifications:send'],
17929
- egressAllow: ['fulfillment.example.com'], // your 3PL/warehouse webhook
17930
- },
17931
- // Abandoned-cart nudge \u2014 hourly cron sweep over stale open carts.
17932
- 'abandoned-cart': {
17933
- entry: './functions/abandoned-cart.ts',
17934
- trigger: { kind: 'cron', schedule: '0 * * * *' },
17935
- // cms:write \u2014 the sweep PATCHes each nudged cart to \`abandoned\` (out of the filter)
17936
- scopes: ['cms:read', 'cms:write', 'notifications:send'],
17937
- },
17938
- },
17939
-
17940
- secrets: {
17941
- stripe_secret: { feature: 'payments', description: 'Stripe secret key (BYO)' },
17942
- stripe_webhook: { feature: 'payments', description: 'Stripe webhook signing secret' },
17943
- },
17944
-
17945
- seed: {
17946
- cms: [
17947
- {
17948
- collection: 'products',
17949
- items: [
17950
- { title: 'Aeron Chair', slug: 'aeron-chair', category: 'furniture', brand: 'Herman Miller', price_cents: 149900, rating_avg: 4.8 },
17951
- { title: 'Standing Desk', slug: 'standing-desk', category: 'furniture', brand: 'Uplift', price_cents: 59900, rating_avg: 4.6 },
17952
- ],
17953
- },
17954
- { collection: 'coupons', items: [{ code: 'WELCOME10', kind: 'percent', value: 10, max_uses: 1000 }] },
17955
- ],
17956
- },
17957
- });
17958
- `,
17959
- "readme": "# Storefront / E-commerce template\n\nA single-seller e-commerce backend \u2014 catalog, variants, owner-scoped carts, coupons, moderated\nreviews, and an exactly-once checkout \u2014 declared in one typed `vxil.config.ts` plus four functions.\nPayments run as a payments integration with **your own Stripe account**: vxil is never in the flow of funds.\n\n**What it provisions:**\n- `products` / `variants` \u2014 the catalog; `variants.stock` is the oversell-safe inventory counter;\n `variants.price_ref` holds the provider price id (e.g. a Stripe Price) the hosted checkout charges by.\n Both are **`public: true`** \u2014 the shopfront (grid + product-detail variants) reads keyless (see below);\n the transactional collections (`carts`/`orders`/`coupons`) are **not** public.\n- `carts` / `cart_items` \u2014 owner-scoped carts per shopper (guest checkout via anonymous auth);\n each line snapshots `unit_price_cents` + `price_ref` at add-to-cart.\n- `orders` \u2014 unique `number` + unique `cart_ref`, with a state-machine hook guarding status transitions.\n- `coupons` / `coupon_redemptions` \u2014 codes plus one row per redemption; cap redemption writes\n with a write-body `guards[]` under one `lock` (the cms \xA710 pattern \u2014 shown in the config, not wired into `checkout.ts`).\n- `reviews` \u2014 owner-scoped, 1\u20135 rating enforced by a hook, moderated via draft/publish.\n- Features: `cms`, `auth`, `payments` (Stripe, BYO keys), `notifications`, `functions`; four functions \u2014\n `checkout` (the saga), `price-cart` (pricing engine), `on-order-paid` (receipt + fulfillment webhook),\n `abandoned-cart` (hourly cron nudge \u2014 one per abandonment: a nudged cart is marked `abandoned`).\n\n**Apply it:**\n\n```bash\nvxil init --template storefront\nvxil quickstart\nvxil secrets set stripe_secret && vxil secrets set stripe_webhook\nvxil push\nvxil seed # demo products + the WELCOME10 coupon\nvxil gen\n```\n\n**What to learn from this:**\n1. **The checkout saga** (`functions/checkout.ts`) \u2014 reserve inventory \u2192 create the order \u2192 open the\n payment, compensating on any failure. All-or-nothing across features, no cross-feature ACID needed.\n Invoke `checkout`/`price-cart` from YOUR backend (server key): with `strictEndUserScope` on, an\n end-user-mode invocation is correctly denied on the shared collections they touch\n (`cart_items`/`variants`/`coupons`) \u2014 inventory is tenant-wide by design.\n2. **Oversell-safety** \u2014 `variants.stock` with `validation: { min: 0 }` makes `PATCH { $inc: { stock: -qty } }`\n a single-statement conditional decrement: exactly one winner under N concurrent buyers.\n3. **Exactly-once placement** \u2014 `orders.cart_ref` is declared `unique: true` in the config (carried by\n `vxil push`, `cms.md` \xA79.3), so N racing checkouts of one cart yield exactly one order \u2014 the rest see\n the `409 unique_violation` and `checkout.ts` answers `already_placed` (idempotent).\n\n```typescript\n// browse the live catalog with a server key (typed SDK)\nconst { items } = await vx.from('products').query({\n filter: { $status: 'published' },\n sort: '-price_cents',\n limit: 25,\n});\n```\n\n4. **The public shopfront \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). `products` and `variants` are\n `public: true`, so a static/JAMstack storefront renders the grid and product-detail pages with **no API\n key** \u2014 the edge forces `status = 'published'` and edge-caches the response. Only the write path (cart,\n checkout, admin) needs a key; the transactional collections are never public.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-price_cents', limit: 24 });\n// a product's sellable variants (price, availability, provider price_ref)\nconst variants = await listCmsPublic('ten_your_tenant_id', 'variants', { filter: { product: productId } });\n```\n\n**Reader UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a product page\nfor the `reviews` thread + a file uploader (enable the `comments` feature) \u2014 a pure client-side component\nover the shipped API. It installs via npm into a bundled app (no served `.mjs`).\n\n**Go deeper:** [`examples/ecommerce`](../../examples/ecommerce) is the fully-annotated deep version of this\nblueprint; vxil.com/docs/guide/04-data-with-cms (**public delivery**), vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (`$inc`, `lock`/`guards[]` on the item write routes),\nvxil.com/docs/guide/06-feature-catalog (payments),\nvxil.com/docs/guide/08-running-your-code-functions, and `templates/catalog/` (a content-only\npublic product grid).\n",
17722
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Storefront\" \u2014 a single-seller e-commerce backend, declared end-to-end in ONE\n// typed file + four functions. There is no vxil \"e-commerce feature\": this is a\n// BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 the relational spine (catalog, variants, carts, orders, coupons, reviews)\n// \u2022 payments \u2192 a payments INTEGRATION with your own Stripe account (checkout\n// sessions + a store-credit ledger; vxil is never in the flow of funds)\n// \u2022 auth \u2192 shopper accounts, incl. anonymous GUEST checkout\n// \u2022 functions \u2192 the domain logic that isn't config: the checkout SAGA + pricing engine\n// \u2022 notifications \u2192 order receipts + abandoned-cart nudges\n// The fully-annotated deep version of this blueprint lives in examples/ecommerce.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // published rows are the live catalog; drafts are staging\n // Fail-safe: a verified end-user may only touch collections that declare an\n // ownerField (carts/orders/reviews) \u2014 never a tenant-wide collection.\n strictEndUserScope: true,\n hooks: {\n // \u2605 THE ORDER STATE MACHINE: reject any illegal status transition, atomically,\n // in the same write tx. (Cross-row work \u2014 the checkout saga \u2014 is a function.)\n order_transition: {\n collection: 'orders',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n \"item.status == before.status\" +\n \" || (before.status == 'pending' && (item.status == 'paid' || item.status == 'cancelled'))\" +\n \" || (before.status == 'paid' && (item.status == 'fulfilled' || item.status == 'refunded'))\" +\n \" || (before.status == 'fulfilled' && item.status == 'shipped')\",\n message: 'illegal order status transition',\n },\n // A review must carry a 1\u20135 rating.\n review_rating: {\n collection: 'reviews',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'item.rating >= 1 && item.rating <= 5',\n message: 'rating must be 1\u20135',\n },\n },\n },\n\n auth: {\n methods: { emailPassword: true, magicLink: true },\n anonymous: { enabled: true }, // guest checkout \u2192 claim the email later\n },\n\n payments: {\n provider: 'stripe',\n stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },\n defaults: { currency: 'usd' },\n // The ledger is for STORE CREDIT / gift cards / loyalty (add-only grants +\n // balance-guarded consume) \u2014 populate productMap/tierMap per vxil.com/docs/guide/06-feature-catalog (payments).\n ledger: { productMap: {}, tierMap: {} },\n },\n\n notifications: { provider: 'mock', fromEmail: 'orders@storefront.example' },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n products: {\n singular: 'product',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the SHOPFRONT \u2014 published products read with\n // NO API key over GET /v1/cms/public/:tenantId/products, edge-cached. This is\n // the anonymous browse tier; the transactional collections below (carts,\n // orders, coupons) are NOT public and stay behind the owner-scoped authed lane.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', unique: true },\n category: { type: 'string', indexSlot: 's3' },\n brand: { type: 'string', indexSlot: 's4' },\n price_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n rating_avg: { type: 'float', indexSlot: 'n2' }, // derived from reviews\n description: { type: 'text' },\n images: { type: 'json' },\n },\n },\n variants: {\n singular: 'variant',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the product-detail page needs the sellable\n // variants (price, availability, provider price_ref) \u2014 all shopfront-public\n // by design. `stock` here is a public availability signal; the oversell-safe\n // DECREMENT still happens only on the authed write lane inside checkout.ts.\n public: true,\n fields: {\n // `indexed` (guide ch. 4, indexed fields): the product page filters variants by\n // product (`{ product: productId }`) \u2014 equality only, so no slot is spent on it.\n product: { type: 'relation', relationTo: 'products', indexed: true },\n sku: { type: 'string', required: true, indexSlot: 's1', unique: true },\n title: { type: 'string', indexSlot: 's2' },\n price_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n // \u2605 THE OVERSELL-SAFE STOCK PATTERN: validation.min:0 makes a `$inc{stock:-qty}`\n // a single-statement conditional decrement (WHERE stock-qty >= 0) \u2014 exactly one\n // 200 under N concurrent buyers, 409 inc_out_of_bounds otherwise. No oversell.\n stock: { type: 'int', indexSlot: 'n2', validation: { min: 0 } },\n // The PROVIDER's price id for this variant (e.g. a Stripe Price) \u2014 the hosted\n // checkout-session charges by provider price ref, so mirror each sellable\n // variant into your Stripe catalog and store its id here (BYO provider).\n price_ref: { type: 'string' },\n options: { type: 'json' }, // { size, color, \u2026 } \u2014 not indexed\n },\n },\n carts: {\n singular: 'cart',\n ownerField: 'end_user', // owner-scoped: a shopper (or guest session) sees only their own carts\n fields: {\n status: { type: 'string', indexSlot: 's1' }, // open | ordered | abandoned\n currency: { type: 'string', indexSlot: 's2' },\n end_user: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n last_activity: { type: 'datetime', indexSlot: 't1' }, // abandoned-cart cron filter\n },\n },\n cart_items: {\n singular: 'cart_item',\n fields: {\n // checkout + price-cart read a cart's lines by `{ cart: cartId }` on every call\n cart: { type: 'relation', relationTo: 'carts', indexed: true },\n variant: { type: 'relation', relationTo: 'variants' },\n qty: { type: 'int', indexSlot: 'n1', validation: { min: 1 } },\n unit_price_cents: { type: 'int', indexSlot: 'n2' }, // price snapshot at add-to-cart\n // provider price-id snapshot at add-to-cart (from variants.price_ref) \u2014\n // checkout's line_items charge by THIS ref (payments checkout-sessions contract).\n price_ref: { type: 'string', required: true },\n },\n },\n orders: {\n singular: 'order',\n ownerField: 'end_user',\n fields: {\n number: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // \u2605 EXACTLY-ONCE PLACEMENT: unique cart_ref \u2014 N racing checkouts of the same\n // cart \u2192 exactly one order insert succeeds (409 on the rest = idempotent).\n cart_ref: { type: 'string', required: true, indexSlot: 's2', unique: true },\n status: { type: 'string', indexSlot: 's3' }, // pending|paid|fulfilled|shipped|refunded|cancelled\n end_user: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id\n total_cents: { type: 'int', indexSlot: 'n1' },\n placed_at: { type: 'datetime', indexSlot: 't1' },\n lines: { type: 'json' }, // [{ variant, qty, unit_price_cents, price_ref }] snapshot\n },\n },\n coupons: {\n singular: 'coupon',\n fields: {\n code: { type: 'string', required: true, indexSlot: 's1', unique: true },\n kind: { type: 'string', indexSlot: 's2' }, // percent | fixed\n value: { type: 'int', indexSlot: 'n1' }, // percent (1\u2013100) or cents\n max_uses: { type: 'int', indexSlot: 'n2' },\n },\n },\n // \u2605 One row per redemption. When you honor a coupon at capture, write the row\n // with a `guards[]` count-cap ON THE WRITE BODY \u2014 { lock: 'coupon:'+code,\n // guards: [{ filter: { code }, max: max_uses }] } (vxil.com/docs/api;\n // guard/guards ride the request, they are NOT config) \u2014 so a coupon can never\n // over-redeem under concurrency. (Not wired into checkout.ts \u2014 add it there.)\n coupon_redemptions: {\n singular: 'coupon_redemption',\n fields: {\n code: { type: 'string', required: true, indexSlot: 's1' },\n order: { type: 'relation', relationTo: 'orders' },\n },\n },\n // Owner-scoped write (a verified shopper posts their own), moderated via draft/publish.\n reviews: {\n singular: 'review',\n ownerField: 'end_user',\n fields: {\n product: { type: 'relation', relationTo: 'products' },\n rating: { type: 'int', required: true, indexSlot: 'n1', validation: { min: 1, max: 5 } },\n title: { type: 'string', indexSlot: 's1' },\n end_user: { type: 'string', indexSlot: 's2' }, // the owner (end-user) id\n body: { type: 'text' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions \u2500\u2500\n functions: {\n // The checkout SAGA \u2014 reserve inventory + create order + open payment, all-or-nothing.\n // Invoke checkout/price-cart SERVER-SIDE (your backend, server key): with\n // strictEndUserScope on, an end-user-mode invocation is correctly denied on the\n // shared collections they touch (cart_items/variants/coupons).\n checkout: {\n entry: './functions/checkout.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'payments:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // The pricing ENGINE \u2014 full JS (stacking/BOGO/tiers/coupons); cross-row cart math\n // is forbidden in hooks by design. Recompute server-side = anti-tamper.\n 'price-cart': {\n entry: './functions/price-cart.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read'],\n },\n // Settlement: on order \u2192 paid, fire the receipt + fulfillment webhook (side-effects).\n 'on-order-paid': {\n entry: './functions/on-order-paid.ts',\n trigger: { kind: 'cmsHook', collection: 'orders', event: 'beforeUpdate' },\n scopes: ['cms:read', 'notifications:send'],\n egressAllow: ['fulfillment.example.com'], // your 3PL/warehouse webhook\n },\n // Abandoned-cart nudge \u2014 hourly cron sweep over stale open carts.\n 'abandoned-cart': {\n entry: './functions/abandoned-cart.ts',\n trigger: { kind: 'cron', schedule: '0 * * * *' },\n // cms:write \u2014 the sweep PATCHes each nudged cart to `abandoned` (out of the filter)\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n },\n },\n\n secrets: {\n stripe_secret: { feature: 'payments', description: 'Stripe secret key (BYO)' },\n stripe_webhook: { feature: 'payments', description: 'Stripe webhook signing secret' },\n },\n\n seed: {\n cms: [\n {\n collection: 'products',\n items: [\n { title: 'Aeron Chair', slug: 'aeron-chair', category: 'furniture', brand: 'Herman Miller', price_cents: 149900, rating_avg: 4.8 },\n { title: 'Standing Desk', slug: 'standing-desk', category: 'furniture', brand: 'Uplift', price_cents: 59900, rating_avg: 4.6 },\n ],\n },\n { collection: 'coupons', items: [{ code: 'WELCOME10', kind: 'percent', value: 10, max_uses: 1000 }] },\n ],\n },\n});\n",
17723
+ "readme": "# Storefront / E-commerce template\n\nA single-seller e-commerce backend \u2014 catalog, variants, owner-scoped carts, coupons, moderated\nreviews, and an exactly-once checkout \u2014 declared in one typed `vxil.config.ts` plus four functions.\nPayments run as a payments integration with **your own Stripe account**: vxil is never in the flow of funds.\n\n**What it provisions:**\n- `products` / `variants` \u2014 the catalog; `variants.stock` is the oversell-safe inventory counter;\n `variants.price_ref` holds the provider price id (e.g. a Stripe Price) the hosted checkout charges by.\n Both are **`public: true`** \u2014 the shopfront (grid + product-detail variants) reads keyless (see below);\n the transactional collections (`carts`/`orders`/`coupons`) are **not** public.\n- `carts` / `cart_items` \u2014 owner-scoped carts per shopper (guest checkout via anonymous auth);\n each line snapshots `unit_price_cents` + `price_ref` at add-to-cart.\n- `orders` \u2014 unique `number` + unique `cart_ref`, with a state-machine hook guarding status transitions.\n- `coupons` / `coupon_redemptions` \u2014 codes plus one row per redemption; cap redemption writes\n with a write-body `guards[]` under one `lock` (the lock + guard pattern, guide ch. 4 \u2014 shown in the config, not wired into `checkout.ts`).\n- `reviews` \u2014 owner-scoped, 1\u20135 rating enforced by a hook, moderated via draft/publish.\n- Features: `cms`, `auth`, `payments` (Stripe, BYO keys), `notifications`, `functions`; four functions \u2014\n `checkout` (the saga), `price-cart` (pricing engine), `on-order-paid` (receipt + fulfillment webhook),\n `abandoned-cart` (hourly cron nudge \u2014 one per abandonment: a nudged cart is marked `abandoned`).\n\n**Apply it:**\n\n```bash\nvxil init --template storefront\nvxil quickstart --env staging --no-push\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil secrets set stripe_secret && vxil secrets set stripe_webhook\nvxil push\nvxil seed # demo products + the WELCOME10 coupon\nvxil gen\n```\n\n> **Plan note.** The four functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n1. **The checkout saga** (`functions/checkout.ts`) \u2014 reserve inventory \u2192 create the order \u2192 open the\n payment, compensating on any failure. All-or-nothing across features, no cross-feature ACID needed.\n Invoke `checkout`/`price-cart` from YOUR backend (server key): with `strictEndUserScope` on, an\n end-user-mode invocation is correctly denied on the shared collections they touch\n (`cart_items`/`variants`/`coupons`) \u2014 inventory is tenant-wide by design.\n2. **Oversell-safety** \u2014 `variants.stock` with `validation: { min: 0 }` makes `PATCH { $inc: { stock: -qty } }`\n a single-statement conditional decrement: exactly one winner under N concurrent buyers.\n3. **Exactly-once placement** \u2014 `orders.cart_ref` is declared `unique: true` in the config (carried by\n `vxil push`, guide ch. 4, uniqueness), so N racing checkouts of one cart yield exactly one order \u2014 the rest see\n the `409 unique_violation` and `checkout.ts` answers `already_placed` (idempotent).\n\n```typescript\n// browse the live catalog with a server key (typed SDK)\nconst { items } = await vx.from('products').query({\n filter: { $status: 'published' },\n sort: '-price_cents',\n limit: 25,\n});\n```\n\n4. **The public shopfront \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). `products` and `variants` are\n `public: true`, so a static/JAMstack storefront renders the grid and product-detail pages with **no API\n key** \u2014 the edge forces `status = 'published'` and edge-caches the response. Only the write path (cart,\n checkout, admin) needs a key; the transactional collections are never public.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-price_cents', limit: 24 });\n// a product's sellable variants (price, availability, provider price_ref)\nconst variants = await listCmsPublic('ten_your_tenant_id', 'variants', { filter: { product: productId } });\n```\n\n5. **Equality without spending a slot** \u2014 `variants.product` and `cart_items.cart` are only ever matched by\n equality (`{ product: productId }`, `{ cart: cartId }`), so they are marked `indexed: true` instead of\n taking one of the eight index slots: those filters are index-served on the authed list and the public\n lane alike (up to 4 per collection; guide ch. 4, indexed fields).\n\n**Reader UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a product page\nfor the `reviews` thread + a file uploader (enable the `comments` feature) \u2014 a pure client-side component\nover the shipped API. It installs via npm into a bundled app (no served `.mjs`).\n\n**Go deeper:** [`examples/ecommerce`](../../examples/ecommerce) is the fully-annotated deep version of this\nblueprint; vxil.com/docs/guide/04-data-with-cms (**public delivery**), vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (`$inc`, `lock`/`guards[]` on the item write routes),\nvxil.com/docs/guide/06-feature-catalog (payments),\nvxil.com/docs/guide/08-running-your-code-functions, and `templates/catalog/` (a content-only\npublic product grid).\n",
17960
17724
  "functions": {
17961
- "abandoned-cart.ts": "// abandoned-cart.ts \u2014 RETENTION CRON (a vxil function, \xA77.3).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep open carts that went stale (last_activity\n// older than 1h) using the slot-indexed range filter, and nudge the shopper. This is the\n// jobs-cron pattern \u2014 no new primitive, just a scheduled function.\n//\n// ONE nudge per abandonment: after the send, the cart is marked `abandoned`, which takes\n// it out of the `status: 'open'` filter \u2014 so the next tick reads the NEXT stale carts\n// instead of the same 100 again (and a shopper is not nudged every hour forever). Your\n// app sets `status` back to `open` (with a fresh `last_activity`) when the shopper\n// touches the cart again; a later abandonment is nudged again. The Idempotency-Key\n// (cart id + last_activity) keeps a redelivered tick, or a failed mark, from\n// sending the same nudge twice.\n\n// cron-walk: drains-filter \u2014 a nudged cart is marked `abandoned` and leaves the `status: 'open'` filter.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Cart { status: string; last_activity: string; end_user?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // carts still `open` whose last_activity is > 1h ago (t1 range filter, index-served)\n const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();\n const filter = enc({ status: 'open', last_activity: { $lt: cutoff } });\n const res = await fetch(`${base}/v1/cms/items/carts?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: Cart }[] } };\n const carts = body.data?.items ?? [];\n\n // POST /v1/notifications/send is { user_id, template, data } \u2014 `transactional` is the\n // shipped generic template (requires data.subject + data.paragraph, notifications.md \xA77).\n let nudged = 0;\n let markFailed = 0;\n for (const c of carts) {\n if (!notif || !c.data.end_user) continue;\n const sent = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n 'idempotency-key': `abandoned-cart:${c.item_id}:${c.data.last_activity}`,\n },\n body: JSON.stringify({\n user_id: c.data.end_user,\n template: 'transactional',\n data: {\n subject: 'You left items in your cart',\n paragraph: `Your cart (${c.item_id}) is still waiting \u2014 come back and finish checkout any time.`,\n },\n }),\n }).then((r) => r.ok).catch(() => false);\n if (!sent) continue; // left open: the next tick tries again (same Idempotency-Key)\n nudged++;\n // Out of the filter: the next tick reads the next stale carts, not these again.\n // The mark needs `cms:write` (declared in vxil.config.ts). A refused mark is\n // COUNTED, never swallowed: the cart stays open, so the next tick re-reads it\n // (the Idempotency-Key keeps the nudge from going out twice) \u2014 and a run that\n // reports `mark_failed > 0` answers 500, so it is SEEN (`functions.run.failed`).\n const marked = await fetch(`${base}/v1/cms/items/carts/${c.item_id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { status: 'abandoned' } }),\n }).then((r) => r.ok).catch(() => false);\n if (!marked) markFailed++;\n }\n return Response.json({ scanned: carts.length, nudged, mark_failed: markFailed }, { status: markFailed > 0 ? 500 : 200 });\n },\n};\n\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n",
17962
- "checkout.ts": "// checkout.ts \u2014 THE CHECKOUT SAGA (a vxil function, \xA77.3).\n//\n// The hard part of e-commerce: \"reserve N SKUs + capture payment + create the order,\n// all-or-nothing\" \u2014 which is the deliberately-REJECTED cross-feature-ACID case. The\n// doctrinal (and incumbent-identical) answer is a reserve\u2192settle\u2192reverse SAGA, and it\n// is exactly-once under any concurrency. Shopify+Stripe do the same thing (Stripe is a\n// physically separate system reconciled by webhook); nothing here is a platform gap.\n//\n// Invoke it SERVER-SIDE (your backend POSTs /v1/fn/checkout with a server key):\n// under cms.strictEndUserScope an end-user-mode invocation is correctly denied on\n// the shared collections this saga touches (cart_items/variants) \u2014 inventory is a\n// tenant-wide surface, so the reserve step is server work by design.\n//\n// Steps:\n// 1. read the cart (owner + currency) + its lines (cms:read)\n// 2. RESERVE each line: PATCH variant {$inc:{stock:-qty}} \u2014 validation.min:0 makes it a\n// single-statement oversell-safe decrement (409 inc_out_of_bounds if insufficient).\n// On any failure \u2192 COMPENSATE (re-$inc the ones already reserved) \u2192 409 out_of_stock.\n// 3. create the ORDER with a unique cart_ref \u2192 EXACTLY-ONCE (409 on a racing duplicate).\n// 4. open a payments checkout-session (mode:payment, Idempotency-Key = order number).\n// 5. return { order_id, checkout_url }. Capture completes async \u2192 functions/on-order-paid.ts.\n// (Not shipped here: if payment never completes, schedule a jobs `deliver_after`\n// release that re-$inc's the reserve.)\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ cart_id?: string; success_url?: string; cancel_url?: string }>;\ninterface Line { variant: string; qty: number; unit_price_cents: number; price_ref: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const pay = env.scoped_jwts?.payments;\n if (!cms || !pay) return json({ error: 'missing cms/payments scope' }, 403);\n const { cart_id, success_url, cancel_url } = env.payload ?? {};\n if (!cart_id) return json({ error: 'cart_id required' }, 400);\n\n const H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n\n // 1. read the cart (its end_user owner + currency), then its lines\n const cartRes = await fetch(`${base}/v1/cms/items/carts/${cart_id}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!cartRes.ok) return json({ error: 'cart_not_found' }, 404);\n const cart = ((await cartRes.json()) as { data?: { data?: { end_user?: string; currency?: string } } }).data?.data ?? {};\n const shopper = env.end_user?.id ?? cart.end_user;\n if (!shopper) return json({ error: 'cart has no owner (end_user)' }, 400);\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n if (lines.length === 0) return json({ error: 'empty cart' }, 400);\n\n // 2. RESERVE inventory line-by-line (oversell-safe $inc). Track for compensation.\n const reserved: Line[] = [];\n for (const ln of lines) {\n const r = await fetch(`${base}/v1/cms/items/variants/${ln.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: -ln.qty } }),\n });\n if (!r.ok) {\n // compensate everything reserved so far, then fail cleanly\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'out_of_stock', variant: ln.variant }, 409);\n }\n reserved.push(ln);\n }\n\n // 3. create the ORDER \u2014 unique cart_ref makes placement exactly-once under concurrency.\n const total = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n const number = `ORD-${cart_id.slice(0, 8)}`;\n const orderRes = await fetch(`${base}/v1/cms/items/orders`, {\n method: 'POST', headers: H(cms),\n body: JSON.stringify({\n data: {\n number, cart_ref: cart_id, status: 'pending', end_user: shopper,\n total_cents: total, placed_at: new Date().toISOString(), lines,\n },\n }),\n });\n if (orderRes.status === 409) {\n // a concurrent checkout already placed this cart \u2192 idempotent: report it placed\n return json({ status: 'already_placed', number }, 200);\n }\n if (!orderRes.ok) {\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'order_create_failed' }, 502);\n }\n const order = (await orderRes.json()) as { data?: { item_id?: string } };\n\n // 4. open the hosted payment (one-time). Idempotency-Key = order number \u21D2 safe to retry.\n // The documented checkout-sessions contract (payments.md \xA73): user_id + line_items\n // [{ price_ref, quantity, amount_cents?, currency? }] + mode + success/cancel URLs.\n // price_ref is the PROVIDER's price id (a Stripe Price) snapshot on the cart line \u2014\n // Stripe's adapter charges by price id; amount_cents/currency serve amount-based\n // providers (PayPal payment mode). NOTE: the shipped Stripe adapter charges\n // line_items[0] only \u2014 for multi-line carts on Stripe, collapse to one provider\n // line (or one order-total price) before opening the session.\n const currency = cart.currency ?? 'usd';\n const sess = await fetch(`${base}/v1/payments/checkout-sessions`, {\n method: 'POST',\n headers: { ...H(pay), 'idempotency-key': number },\n body: JSON.stringify({\n user_id: shopper,\n mode: 'payment',\n line_items: lines.map((l) => ({ price_ref: l.price_ref, quantity: l.qty, amount_cents: l.unit_price_cents, currency })),\n success_url: success_url ?? 'https://storefront.example/checkout/success',\n cancel_url: cancel_url ?? 'https://storefront.example/checkout/cancel',\n }),\n });\n if (!sess.ok) {\n // the order stays placed (pending) \u2014 surface the payment error so the caller can\n // retry the session (same Idempotency-Key) after fixing price_refs / provider keys.\n return json({ order_id: order.data?.item_id, number, error: 'payment_session_failed' }, 502);\n }\n const s = (await sess.json()) as { data?: { url?: string } };\n\n return json({ order_id: order.data?.item_id, number, checkout_url: s.data?.url }, 201);\n },\n};\n\n// \u2500\u2500 tiny helpers (the vxil REST envelope is { data: { items }, meta }; items carry item_id) \u2500\u2500\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
17963
- "on-order-paid.ts": "// on-order-paid.ts \u2014 SETTLEMENT SIDE-EFFECTS (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.updated for `orders`. When the order flips to\n// `paid` \u2014 YOUR payment-success handler PATCHes it (e.g. a function subscribed to the\n// payments `payments.charge.succeeded` event via a webhooks-out subscription on the\n// `payments.` prefix, or your backend after the hosted checkout returns); the\n// order_transition hook validates the flip \u2014 fan out the side-effects:\n// email the receipt (notifications) and POST the fulfillment webhook to the tenant's\n// 3PL/warehouse over the egress allowlist. Delivery is at-least-once with retry/DLQ \u2014\n// identical semantics to Shopify Flow / a Stripe webhook fan-out.\n//\n// The cms-hook payload is { event, collection, item_id } \u2014 NOT the row \u2014 so the\n// function RE-FETCHES the order by id (through the edge, tenant-scoped). notifications:send\n// is a legitimate function scope (allowed by the deploy; https://vxil.com/docs/guide/08-running-your-code-functions).\n//\n// (Inventory was already reserved atomically at checkout, so there is no decrement here \u2014\n// the reservation simply becomes permanent. A payment FAILURE path compensates instead.)\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface OrderData { number?: string; status?: string; total_cents?: number; end_user?: string }\ninterface Item { data?: { data?: OrderData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'orders' || !cms || !itemId) return Response.json({ skipped: true });\n\n // Re-fetch the order (the payload carries only the id) and act only on pending\u2192paid.\n const res = await fetch(`${base}/v1/cms/items/orders/${itemId}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const order = ((await res.json()) as Item).data?.data ?? {};\n if (order.status !== 'paid') return Response.json({ skipped: true, status: order.status });\n\n // 1. receipt email (in-app inbox + email via the configured provider).\n if (notif && order.end_user) {\n await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notif}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_id: order.end_user,\n template: 'transactional',\n data: { subject: `Receipt for order ${order.number}`, paragraph: `Thanks! Your order ${order.number} totalling ${order.total_cents} cents is confirmed.` },\n }),\n }).catch(() => { /* the jobs/webhooks retry+DLQ engine owns durability */ });\n }\n\n // 2. fulfillment webhook to the tenant's warehouse (egress-guarded to fulfillment.example.com).\n await fetch('https://fulfillment.example.com/orders', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ number: order.number, total_cents: order.total_cents }),\n }).catch(() => { /* best-effort here */ });\n\n return Response.json({ settled: order.number });\n },\n};\n",
17964
- "price-cart.ts": "// price-cart.ts \u2014 THE PRICING ENGINE (a vxil function, \xA77.3).\n//\n// This is the module people assume needs a \"promotions feature\". It does NOT \u2014 and it\n// deliberately is NOT a cms lifecycle hook: hooks are single-row and cross-row aggregation\n// is forbidden by design (hooks.ts), so a hook can't sum a cart, apply BOGO across items,\n// or evaluate cart-level thresholds. That is arbitrary domain logic \u2192 a FUNCTION with full\n// JS expressiveness (exactly how Shopify Functions / Scripts run tenant discount code) \u2192 [B].\n//\n// It reads the cart lines + coupon (cms:read) and returns the priced cart. Like checkout,\n// invoke it SERVER-SIDE: under cms.strictEndUserScope the shared collections it reads\n// (cart_items/coupons) are correctly denied to an end-user-mode invocation. checkout.ts\n// recomputes its total from the same server-held snapshots \u2014 never trust a client total;\n// to honor promotions at capture time, apply this function's output there the same way.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ cart_id?: string; coupon_code?: string }>;\ninterface Line { variant: string; qty: number; unit_price_cents: number }\ninterface Coupon { code: string; kind: string; value: number; max_uses: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const { cart_id, coupon_code } = env.payload ?? {};\n if (!cart_id) return Response.json({ error: 'cart_id required' }, { status: 400 });\n\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n\n // subtotal (cross-row sum \u2014 the thing a hook can't do)\n const subtotal = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n\n // \u2500\u2500 arbitrary promotion rules, plain JS \u2500\u2500\n let discount = 0;\n const applied: string[] = [];\n\n // BOGO on any 2+ identical lines: cheapest unit free per pair\n for (const l of lines) {\n if (l.qty >= 2) { discount += Math.floor(l.qty / 2) * l.unit_price_cents; applied.push('bogo'); }\n }\n\n // tiered cart threshold: 5% over $100, 10% over $250\n if (subtotal >= 25000) { discount += Math.round(subtotal * 0.10); applied.push('tier-10'); }\n else if (subtotal >= 10000) { discount += Math.round(subtotal * 0.05); applied.push('tier-5'); }\n\n // coupon (percent or fixed) \u2014 stacks on top, capped so total never goes below 0\n if (coupon_code) {\n const [c] = await get<Coupon>(`${base}/v1/cms/items/coupons?filter=${enc({ code: coupon_code })}&limit=1`, cms);\n if (c) {\n discount += c.kind === 'percent' ? Math.round(subtotal * (c.value / 100)) : c.value;\n applied.push(`coupon:${c.code}`);\n }\n }\n\n const total = Math.max(0, subtotal - discount);\n return Response.json({ subtotal_cents: subtotal, discount_cents: subtotal - total, total_cents: total, applied });\n },\n};\n\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
17725
+ "abandoned-cart.ts": "// abandoned-cart.ts \u2014 RETENTION CRON (a vxil function).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep open carts that went stale (last_activity\n// older than 1h) using the slot-indexed range filter, and nudge the shopper. This is the\n// jobs-cron pattern \u2014 no new primitive, just a scheduled function.\n//\n// ONE nudge per abandonment: after the send, the cart is marked `abandoned`, which takes\n// it out of the `status: 'open'` filter \u2014 so the next tick reads the NEXT stale carts\n// instead of the same 100 again (and a shopper is not nudged every hour forever). Your\n// app sets `status` back to `open` (with a fresh `last_activity`) when the shopper\n// touches the cart again; a later abandonment is nudged again. The Idempotency-Key\n// (cart id + last_activity) keeps a redelivered tick, or a failed mark, from\n// sending the same nudge twice.\n\n// cron-walk: drains-filter \u2014 a nudged cart is marked `abandoned` and leaves the `status: 'open'` filter.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Cart { status: string; last_activity: string; end_user?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // carts still `open` whose last_activity is > 1h ago (t1 range filter, index-served)\n const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();\n const filter = enc({ status: 'open', last_activity: { $lt: cutoff } });\n const res = await fetch(`${base}/v1/cms/items/carts?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: Cart }[] } };\n const carts = body.data?.items ?? [];\n\n // POST /v1/notifications/send is { user_id, template, data } \u2014 `transactional` is the\n // shipped generic template (requires data.subject + data.paragraph, guide ch. 6, notifications).\n let nudged = 0;\n let markFailed = 0;\n for (const c of carts) {\n if (!notif || !c.data.end_user) continue;\n const sent = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n 'idempotency-key': `abandoned-cart:${c.item_id}:${c.data.last_activity}`,\n },\n body: JSON.stringify({\n user_id: c.data.end_user,\n template: 'transactional',\n data: {\n subject: 'You left items in your cart',\n paragraph: `Your cart (${c.item_id}) is still waiting \u2014 come back and finish checkout any time.`,\n },\n }),\n }).then((r) => r.ok).catch(() => false);\n if (!sent) continue; // left open: the next tick tries again (same Idempotency-Key)\n nudged++;\n // Out of the filter: the next tick reads the next stale carts, not these again.\n // The mark needs `cms:write` (declared in vxil.config.ts). A refused mark is\n // COUNTED, never swallowed: the cart stays open, so the next tick re-reads it\n // (the Idempotency-Key keeps the nudge from going out twice) \u2014 and a run that\n // reports `mark_failed > 0` answers 500, so it is SEEN (`functions.run.failed`).\n const marked = await fetch(`${base}/v1/cms/items/carts/${c.item_id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { status: 'abandoned' } }),\n }).then((r) => r.ok).catch(() => false);\n if (!marked) markFailed++;\n }\n return Response.json({ scanned: carts.length, nudged, mark_failed: markFailed }, { status: markFailed > 0 ? 500 : 200 });\n },\n};\n\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n",
17726
+ "checkout.ts": "// checkout.ts \u2014 THE CHECKOUT SAGA (a vxil function).\n//\n// The hard part of e-commerce: \"reserve N SKUs + capture payment + create the order,\n// all-or-nothing\" \u2014 which is the deliberately-REJECTED cross-feature-ACID case. The\n// doctrinal (and incumbent-identical) answer is a reserve\u2192settle\u2192reverse SAGA, and it\n// is exactly-once under any concurrency. Shopify+Stripe do the same thing (Stripe is a\n// physically separate system reconciled by webhook); nothing here is a platform gap.\n//\n// Invoke it SERVER-SIDE (your backend POSTs /v1/fn/checkout with a server key):\n// under cms.strictEndUserScope an end-user-mode invocation is correctly denied on\n// the shared collections this saga touches (cart_items/variants) \u2014 inventory is a\n// tenant-wide surface, so the reserve step is server work by design.\n//\n// Steps:\n// 1. read the cart (owner + currency) + its lines (cms:read)\n// 2. RESERVE each line: PATCH variant {$inc:{stock:-qty}} \u2014 validation.min:0 makes it a\n// single-statement oversell-safe decrement (409 inc_out_of_bounds if insufficient).\n// On any failure \u2192 COMPENSATE (re-$inc the ones already reserved) \u2192 409 out_of_stock.\n// 3. create the ORDER with a unique cart_ref \u2192 EXACTLY-ONCE (409 on a racing duplicate).\n// 4. open a payments checkout-session (mode:payment, Idempotency-Key = order number).\n// 5. return { order_id, checkout_url }. Capture completes async \u2192 functions/on-order-paid.ts.\n// (Not shipped here: if payment never completes, schedule a jobs `deliver_after`\n// release that re-$inc's the reserve.)\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ cart_id?: string; success_url?: string; cancel_url?: string }>;\ninterface Line { variant: string; qty: number; unit_price_cents: number; price_ref: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const pay = env.scoped_jwts?.payments;\n if (!cms || !pay) return json({ error: 'missing cms/payments scope' }, 403);\n const { cart_id, success_url, cancel_url } = env.payload ?? {};\n if (!cart_id) return json({ error: 'cart_id required' }, 400);\n\n const H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n\n // 1. read the cart (its end_user owner + currency), then its lines\n const cartRes = await fetch(`${base}/v1/cms/items/carts/${cart_id}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!cartRes.ok) return json({ error: 'cart_not_found' }, 404);\n const cart = ((await cartRes.json()) as { data?: { data?: { end_user?: string; currency?: string } } }).data?.data ?? {};\n const shopper = env.end_user?.id ?? cart.end_user;\n if (!shopper) return json({ error: 'cart has no owner (end_user)' }, 400);\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n if (lines.length === 0) return json({ error: 'empty cart' }, 400);\n\n // 2. RESERVE inventory line-by-line (oversell-safe $inc). Track for compensation.\n const reserved: Line[] = [];\n for (const ln of lines) {\n const r = await fetch(`${base}/v1/cms/items/variants/${ln.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: -ln.qty } }),\n });\n if (!r.ok) {\n // compensate everything reserved so far, then fail cleanly\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'out_of_stock', variant: ln.variant }, 409);\n }\n reserved.push(ln);\n }\n\n // 3. create the ORDER \u2014 unique cart_ref makes placement exactly-once under concurrency.\n const total = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n const number = `ORD-${cart_id.slice(0, 8)}`;\n const orderRes = await fetch(`${base}/v1/cms/items/orders`, {\n method: 'POST', headers: H(cms),\n body: JSON.stringify({\n data: {\n number, cart_ref: cart_id, status: 'pending', end_user: shopper,\n total_cents: total, placed_at: new Date().toISOString(), lines,\n },\n }),\n });\n if (orderRes.status === 409) {\n // a concurrent checkout already placed this cart \u2192 idempotent: report it placed\n return json({ status: 'already_placed', number }, 200);\n }\n if (!orderRes.ok) {\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'order_create_failed' }, 502);\n }\n const order = (await orderRes.json()) as { data?: { item_id?: string } };\n\n // 4. open the hosted payment (one-time). Idempotency-Key = order number \u21D2 safe to retry.\n // The documented checkout-sessions contract (guide ch. 6, payments): user_id + line_items\n // [{ price_ref, quantity, amount_cents?, currency? }] + mode + success/cancel URLs.\n // price_ref is the PROVIDER's price id (a Stripe Price) snapshot on the cart line \u2014\n // Stripe's adapter charges by price id; amount_cents/currency serve amount-based\n // providers (PayPal payment mode). NOTE: the shipped Stripe adapter charges\n // line_items[0] only \u2014 for multi-line carts on Stripe, collapse to one provider\n // line (or one order-total price) before opening the session.\n const currency = cart.currency ?? 'usd';\n const sess = await fetch(`${base}/v1/payments/checkout-sessions`, {\n method: 'POST',\n headers: { ...H(pay), 'idempotency-key': number },\n body: JSON.stringify({\n user_id: shopper,\n mode: 'payment',\n line_items: lines.map((l) => ({ price_ref: l.price_ref, quantity: l.qty, amount_cents: l.unit_price_cents, currency })),\n success_url: success_url ?? 'https://storefront.example/checkout/success',\n cancel_url: cancel_url ?? 'https://storefront.example/checkout/cancel',\n }),\n });\n if (!sess.ok) {\n // the order stays placed (pending) \u2014 surface the payment error so the caller can\n // retry the session (same Idempotency-Key) after fixing price_refs / provider keys.\n return json({ order_id: order.data?.item_id, number, error: 'payment_session_failed' }, 502);\n }\n const s = (await sess.json()) as { data?: { url?: string } };\n\n return json({ order_id: order.data?.item_id, number, checkout_url: s.data?.url }, 201);\n },\n};\n\n// \u2500\u2500 tiny helpers (the vxil REST envelope is { data: { items }, meta }; items carry item_id) \u2500\u2500\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
17727
+ "on-order-paid.ts": "// on-order-paid.ts \u2014 SETTLEMENT SIDE-EFFECTS (a vxil function).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.updated for `orders`. When the order flips to\n// `paid` \u2014 YOUR payment-success handler PATCHes it (e.g. a function subscribed to the\n// payments `payments.charge.succeeded` event via a webhooks-out subscription on the\n// `payments.` prefix, or your backend after the hosted checkout returns); the\n// order_transition hook validates the flip \u2014 fan out the side-effects:\n// email the receipt (notifications) and POST the fulfillment webhook to the tenant's\n// 3PL/warehouse over the egress allowlist. Delivery is at-least-once with retry/DLQ \u2014\n// identical semantics to Shopify Flow / a Stripe webhook fan-out.\n//\n// The cms-hook payload is { event, collection, item_id } \u2014 NOT the row \u2014 so the\n// function RE-FETCHES the order by id (through the edge, tenant-scoped). notifications:send\n// is a legitimate function scope (allowed by the deploy; https://vxil.com/docs/guide/08-running-your-code-functions).\n//\n// (Inventory was already reserved atomically at checkout, so there is no decrement here \u2014\n// the reservation simply becomes permanent. A payment FAILURE path compensates instead.)\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface OrderData { number?: string; status?: string; total_cents?: number; end_user?: string }\ninterface Item { data?: { data?: OrderData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'orders' || !cms || !itemId) return Response.json({ skipped: true });\n\n // Re-fetch the order (the payload carries only the id) and act only on pending\u2192paid.\n const res = await fetch(`${base}/v1/cms/items/orders/${itemId}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const order = ((await res.json()) as Item).data?.data ?? {};\n if (order.status !== 'paid') return Response.json({ skipped: true, status: order.status });\n\n // 1. receipt email (in-app inbox + email via the configured provider).\n if (notif && order.end_user) {\n await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notif}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_id: order.end_user,\n template: 'transactional',\n data: { subject: `Receipt for order ${order.number}`, paragraph: `Thanks! Your order ${order.number} totalling ${order.total_cents} cents is confirmed.` },\n }),\n }).catch(() => { /* the jobs/webhooks retry+DLQ engine owns durability */ });\n }\n\n // 2. fulfillment webhook to the tenant's warehouse (egress-guarded to fulfillment.example.com).\n await fetch('https://fulfillment.example.com/orders', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ number: order.number, total_cents: order.total_cents }),\n }).catch(() => { /* best-effort here */ });\n\n return Response.json({ settled: order.number });\n },\n};\n",
17728
+ "price-cart.ts": "// price-cart.ts \u2014 THE PRICING ENGINE (a vxil function).\n//\n// This is the module people assume needs a \"promotions feature\". It does NOT \u2014 and it\n// deliberately is NOT a cms lifecycle hook: hooks are single-row and cross-row aggregation\n// is forbidden by design (guide ch. 7), so a hook can't sum a cart, apply BOGO across items,\n// or evaluate cart-level thresholds. That is arbitrary domain logic \u2192 a FUNCTION with full\n// JS expressiveness (exactly how Shopify Functions / Scripts run tenant discount code).\n//\n// It reads the cart lines + coupon (cms:read) and returns the priced cart. Like checkout,\n// invoke it SERVER-SIDE: under cms.strictEndUserScope the shared collections it reads\n// (cart_items/coupons) are correctly denied to an end-user-mode invocation. checkout.ts\n// recomputes its total from the same server-held snapshots \u2014 never trust a client total;\n// to honor promotions at capture time, apply this function's output there the same way.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ cart_id?: string; coupon_code?: string }>;\ninterface Line { variant: string; qty: number; unit_price_cents: number }\ninterface Coupon { code: string; kind: string; value: number; max_uses: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const { cart_id, coupon_code } = env.payload ?? {};\n if (!cart_id) return Response.json({ error: 'cart_id required' }, { status: 400 });\n\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n\n // subtotal (cross-row sum \u2014 the thing a hook can't do)\n const subtotal = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n\n // \u2500\u2500 arbitrary promotion rules, plain JS \u2500\u2500\n let discount = 0;\n const applied: string[] = [];\n\n // BOGO on any 2+ identical lines: cheapest unit free per pair\n for (const l of lines) {\n if (l.qty >= 2) { discount += Math.floor(l.qty / 2) * l.unit_price_cents; applied.push('bogo'); }\n }\n\n // tiered cart threshold: 5% over $100, 10% over $250\n if (subtotal >= 25000) { discount += Math.round(subtotal * 0.10); applied.push('tier-10'); }\n else if (subtotal >= 10000) { discount += Math.round(subtotal * 0.05); applied.push('tier-5'); }\n\n // coupon (percent or fixed) \u2014 stacks on top, capped so total never goes below 0\n if (coupon_code) {\n const [c] = await get<Coupon>(`${base}/v1/cms/items/coupons?filter=${enc({ code: coupon_code })}&limit=1`, cms);\n if (c) {\n discount += c.kind === 'percent' ? Math.round(subtotal * (c.value / 100)) : c.value;\n applied.push(`coupon:${c.code}`);\n }\n }\n\n const total = Math.max(0, subtotal - discount);\n return Response.json({ subtotal_cents: subtotal, discount_cents: subtotal - total, total_cents: total, applied });\n },\n};\n\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
17965
17729
  }
17966
17730
  },
17967
17731
  {
@@ -17978,15 +17742,15 @@ export default defineConfig({
17978
17742
  ],
17979
17743
  "hasFunctions": false,
17980
17744
  "byoKeys": [],
17981
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Catalog\" \u2014 a CONTENT-ONLY product catalog (browse/list products by category),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 categories \u2192 products (resolved by relation)\n// This is the catalog CONTENT SHELL, not a storefront: checkout/inventory/orders\n// are the full `examples/ecommerce` blueprint (a reserve\u2192settle\u2192reverse saga in\n// tenant functions). Keep those in a function; a catalog is pure content.\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // draft products while staging; publish to list them\n hooks: {\n // Price (if given) can't be negative \u2014 a pure function of the row.\n product_price: {\n collection: 'products',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'isNull(item.price_cents) || item.price_cents >= 0',\n message: 'price_cents must be >= 0',\n },\n },\n },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n categories: {\n singular: 'category',\n // PUBLIC DELIVERY (cms.md \xA716): the category nav reads keyless too, so a\n // static storefront front-end can render the browse tree with no API key.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n products: {\n singular: 'product',\n // PUBLIC DELIVERY (cms.md \xA716): a content catalog IS a reader-facing surface \u2014\n // published products are readable with NO API key over\n // GET /v1/cms/public/:tenantId/products, edge-cached, drafts never served.\n // A JAMstack storefront (or the served `listCmsPublic()` helper) fetches the\n // grid anonymously; only your admin writes need a key.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', unique: true },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's3' },\n brand: { type: 'string', indexSlot: 's4' },\n price_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n rating: { type: 'float', indexSlot: 'n2' },\n description: { type: 'text' },\n images: { type: 'json' }, // [\"obj_\u2026\", \u2026] file refs or urls\n in_stock: { type: 'bool' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'products',\n items: [\n { title: 'Aeron Chair', slug: 'aeron-chair', brand: 'Herman Miller', price_cents: 149900, rating: 4.8, in_stock: true },\n { title: 'Standing Desk', slug: 'standing-desk', brand: 'Uplift', price_cents: 59900, rating: 4.6, in_stock: true },\n ],\n },\n ],\n },\n});\n",
17982
- "readme": "# Product Catalog template\n\nA **content-only** product catalog \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name + unique slug. **`public: true`** \u2014 the browse nav reads keyless.\n- `products` \u2014 title, unique slug, `category` relation (slot-bound for filtering), brand, `price_cents`\n (`min: 0`), rating, JSON `images`, `in_stock`. A Lane-A hook rejects a negative price. **`public: true`** \u2014\n the product grid reads keyless (see below).\n\n**This is the catalog shell, not a storefront.** Checkout, inventory, orders, and coupons \u2014 the transactional spine\n\u2014 are the full **`examples/ecommerce`** blueprint (a reserve\u2192settle\u2192reverse saga in tenant `functions`, plus the\noversell-safe `$inc` stock pattern and `guards[]` coupon caps). A catalog is pure content; add the commerce logic\nfrom that example when you need it.\n\n**Use it:**\n\n```bash\nvxil init --template catalog\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The same invariant, two layers** \u2014 `price_cents` carries declarative `validation: { min: 0 }` AND a Lane-A\n validate hook: field validation covers one field, a hook expression can span the whole row\n (vxil.com/docs/guide/07-validation-and-hooks).\n- **Numeric slots buy range + sort** \u2014 `price_cents` (`n1`) and `rating` (`n2`) make \"under $100, best-rated\n first\" a fully index-served query (\xA73).\n- **Content shell vs transactional spine** \u2014 cross-row commerce math (carts, stock, coupons) belongs in\n `functions`, never in a Lane-A hook.\n\n```ts\n// storefront browse with a server key: published products under $100, best-rated first\nconst { items } = await vx.from('products').query({\n filter: { price_cents: { $lte: 10000 }, $status: 'published' }, sort: '-rating', limit: 24,\n});\n```\n\n**The public grid \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `products` and `categories` are\n`public: true`, a static storefront renders with **no API key**: the edge forces `status = 'published'`\n(drafts never served) and edge-caches the response. Only admin writes need a key.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-rating', limit: 24 });\n// one category's products \u2014 the \xA712.1 single-hop dotted-key join\nconst inCat = await listCmsPublic('ten_your_tenant_id', 'products', { filter: { 'category.slug': 'furniture' } });\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (queries \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7 vxil.com/docs/api (the item write routes) \xB7\n`templates/storefront/` (the full commerce blueprint with checkout + a public shopfront) \xB7\n`examples/ecommerce/` (the full commerce saga) \xB7 `templates/blog/` (the same content shape for posts).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
17745
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Catalog\" \u2014 a CONTENT-ONLY product catalog (browse/list products by category),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 categories \u2192 products (resolved by relation)\n// This is the catalog CONTENT SHELL, not a storefront: checkout/inventory/orders\n// are the full `examples/ecommerce` blueprint (a reserve\u2192settle\u2192reverse saga in\n// tenant functions). Keep those in a function; a catalog is pure content.\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // draft products while staging; publish to list them\n hooks: {\n // Price (if given) can't be negative \u2014 a pure function of the row.\n product_price: {\n collection: 'products',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'isNull(item.price_cents) || item.price_cents >= 0',\n message: 'price_cents must be >= 0',\n },\n },\n },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n categories: {\n singular: 'category',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the category nav reads keyless too, so a\n // static storefront front-end can render the browse tree with no API key.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n products: {\n singular: 'product',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): a content catalog IS a reader-facing surface \u2014\n // published products are readable with NO API key over\n // GET /v1/cms/public/:tenantId/products, edge-cached, drafts never served.\n // A JAMstack storefront (or the served `listCmsPublic()` helper) fetches the\n // grid anonymously; only your admin writes need a key.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', unique: true },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's3' },\n brand: { type: 'string', indexSlot: 's4' },\n price_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n rating: { type: 'float', indexSlot: 'n2' },\n description: { type: 'text' },\n images: { type: 'json' }, // [\"obj_\u2026\", \u2026] file refs or urls\n in_stock: { type: 'bool' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'products',\n items: [\n { title: 'Aeron Chair', slug: 'aeron-chair', brand: 'Herman Miller', price_cents: 149900, rating: 4.8, in_stock: true },\n { title: 'Standing Desk', slug: 'standing-desk', brand: 'Uplift', price_cents: 59900, rating: 4.6, in_stock: true },\n ],\n },\n ],\n },\n});\n",
17746
+ "readme": "# Product Catalog template\n\nA **content-only** product catalog \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name + unique slug. **`public: true`** \u2014 the browse nav reads keyless.\n- `products` \u2014 title, unique slug, `category` relation (slot-bound for filtering), brand, `price_cents`\n (`min: 0`), rating, JSON `images`, `in_stock`. A Lane-A hook rejects a negative price. **`public: true`** \u2014\n the product grid reads keyless (see below).\n\n**This is the catalog shell, not a storefront.** Checkout, inventory, orders, and coupons \u2014 the transactional spine\n\u2014 are the full **`examples/ecommerce`** blueprint (a reserve\u2192settle\u2192reverse saga in tenant `functions`, plus the\noversell-safe `$inc` stock pattern and `guards[]` coupon caps). A catalog is pure content; add the commerce logic\nfrom that example when you need it.\n\n**Use it:**\n\n```bash\nvxil init --template catalog\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The same invariant, two layers** \u2014 `price_cents` carries declarative `validation: { min: 0 }` AND a Lane-A\n validate hook: field validation covers one field, a hook expression can span the whole row\n (vxil.com/docs/guide/07-validation-and-hooks).\n- **Numeric slots buy range + sort** \u2014 `price_cents` (`n1`) and `rating` (`n2`) make \"under $100, best-rated\n first\" a fully index-served query (guide ch. 4, querying).\n- **Content shell vs transactional spine** \u2014 cross-row commerce math (carts, stock, coupons) belongs in\n `functions`, never in a Lane-A hook.\n\n```ts\n// storefront browse with a server key: published products under $100, best-rated first\nconst { items } = await vx.from('products').query({\n filter: { price_cents: { $lte: 10000 }, $status: 'published' }, sort: '-rating', limit: 24,\n});\n```\n\n**The public grid \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `products` and `categories` are\n`public: true`, a static storefront renders with **no API key**: the edge forces `status = 'published'`\n(drafts never served) and edge-caches the response. Only admin writes need a key.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-rating', limit: 24 });\n// one category's products \u2014 the single-hop dotted-key join (guide ch. 4, relational depth)\nconst inCat = await listCmsPublic('ten_your_tenant_id', 'products', { filter: { 'category.slug': 'furniture' } });\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (queries \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7 vxil.com/docs/api (the item write routes) \xB7\n`templates/storefront/` (the full commerce blueprint with checkout + a public shopfront) \xB7\n`examples/ecommerce/` (the full commerce saga) \xB7 `templates/blog/` (the same content shape for posts).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
17983
17747
  "functions": {}
17984
17748
  },
17985
17749
  {
17986
17750
  "id": "bookings",
17987
17751
  "title": "Event Bookings",
17988
17752
  "vertical": "scheduling",
17989
- "summary": "Events/appointments with race-proof capacity \u2014 one advisory lock + guards[] so N concurrent bookings of the last seat yield exactly one 201.",
17753
+ "summary": "Events/appointments with race-proof capacity \u2014 one per-key lock + guards[] so N concurrent bookings of the last seat yield exactly one 201.",
17990
17754
  "collections": [
17991
17755
  "events",
17992
17756
  "bookings"
@@ -18025,8 +17789,8 @@ export default defineConfig({
18025
17789
  // ]
18026
17790
  // }
18027
17791
  //
18028
- // \`lock\` serializes every writer of THIS event (a per-(tenant,collection,key)
18029
- // advisory lock); each guard asserts "after this write, at most \`max\` live
17792
+ // \`lock\` serializes every writer of THIS event (a per-(project,collection,key)
17793
+ // lock taken at the start of the write); each guard asserts "after this write, at most \`max\` live
18030
17794
  // items match \`filter\`", all evaluated in ONE transaction under the ONE lock.
18031
17795
  // N concurrent bookings of the last seat \u2192 exactly one 201; the rest get a
18032
17796
  // clean 409 \`guard_failed\` carrying the failing guard's \`message\`. Cancelling
@@ -18040,7 +17804,7 @@ export default defineConfig({
18040
17804
  cms: {
18041
17805
  // Events go live by publishing. Drafts are NOT auto-hidden \u2014 booker-facing
18042
17806
  // lists must filter { "$status": "published" } ($status = the lifecycle;
18043
- // a bare \`status\` matches events' OWN status field \u2014 cms.md \xA73).
17807
+ // a bare \`status\` matches events' OWN status field \u2014 guide ch. 4, querying).
18044
17808
  draftPublish: true,
18045
17809
  hooks: {
18046
17810
  // A booking's status is a closed enum \u2014 a pure function of the row
@@ -18106,7 +17870,7 @@ export default defineConfig({
18106
17870
  },
18107
17871
  });
18108
17872
  `,
18109
- "readme": '# Event Bookings template\n\nEvents/appointments with **race-proof capacity** \u2014 declared end-to-end in one typed `vxil.config.ts`.\nCapacity here is not a hope or a cron sweep: it is a declarative invariant enforced atomically at write\ntime, so overselling is structurally impossible.\n\n**Provisions:**\n- `events` \u2014 title, unique slug, `starts_at`/`ends_at` (slot-bound for range queries), `capacity`, status.\n- `bookings` \u2014 `event` relation, `member` (the **owner field** \u2014 a booker only sees their own),\n status (`booked | cancelled`, enforced by a Lane-A validate hook), `booked_at`.\n- `auth` (email/password) \u2014 a booker is a verified end-user.\n- `notifications` \u2014 send confirmations from your own code (`mock` provider until you wire a real one).\n\n**Use it:**\n\n```bash\nvxil init --template bookings\nvxil quickstart\nvxil push\nvxil seed # the demo event ("Launch Workshop", capacity 3) \u2014 push alone doesn\'t seed\nvxil gen\n```\n\n**What to learn from this:**\n1. **Per-key serialization (`lock`).** `lock: "evt:<event_id>"` takes a per-(tenant,collection,key)\n advisory lock as the first statement of the write transaction \u2014 every writer of THIS event queues;\n writers of other events don\'t contend.\n2. **Multi-invariant `guards[]`.** Each guard asserts *"after this write, at most `max` live items match\n `filter`"* \u2014 seat capacity AND one-booking-per-member, checked co-atomically under the ONE lock.\n Guards ride the **write request body**, never config (vxil.com/docs/api: the item write routes).\n3. **Exactly one winner.** N concurrent bookings of the last seat: the lock serializes them, the first\n passes both counts and commits (**201**); every later one counts a full event and gets **409 `guard_failed`**\n with your `message` ("event is full"). Cancelling (PATCH `status` \u2192 `\'cancelled\'`) frees the seat,\n since the guard filters only count `status: \'booked\'` rows.\n\nThe canonical oversell-proof write (curl; `$EVENT_ID` = the seeded event, capacity 3 \u2014 note the\ntop-level `"status"` is the draft/published **lifecycle**, distinct from the booking\'s own `status` field):\n\n```bash\ncurl -s -X POST "$EDGE/v1/cms/items/bookings" \\\n -H "Authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d \'{\n "data": { "event": "\'$EVENT_ID\'", "member": "\'$ME\'", "status": "booked", "booked_at": "2026-08-01T17:00:00Z" },\n "status": "published",\n "lock": "evt:\'$EVENT_ID\'",\n "guards": [\n { "filter": { "event": "\'$EVENT_ID\'", "status": "booked" }, "max": 3, "message": "event is full" },\n { "filter": { "event": "\'$EVENT_ID\'", "member": "\'$ME\'", "status": "booked" }, "max": 1, "message": "already booked" }\n ]\n }\'\n```\n\nOr the typed SDK \u2014 `vx.from(...).create` takes `lock`/`guards` as write options:\n\n```ts\nconst { item_id } = await vx.from(\'bookings\').create(\n { event: eventId, member: me, status: \'booked\', booked_at: new Date().toISOString() },\n {\n status: \'published\',\n lock: `evt:${eventId}`,\n guards: [\n { filter: { event: eventId, status: \'booked\' }, max: capacity, message: \'event is full\' },\n { filter: { event: eventId, member: me, status: \'booked\' }, max: 1, message: \'already booked\' },\n ],\n },\n);\n```\n\n(`max` is the event\'s `capacity` \u2014 read the event row first; the guard re-counts atomically at write\ntime, so a stale seat COUNT can never oversell. A concurrently edited `capacity` is still read\npre-lock \u2014 pass the freshest value you have.)\n\n**Go deeper:** vxil.com/docs/api (lock/guard/guards on the item write routes), vxil.com/docs/guide/07-validation-and-hooks (Lane-A hooks), vxil.com/docs/guide/04-data-with-cms (owner-scoping);\nvxil.com/docs/guide/06-feature-catalog (auth); `examples/ecommerce/`, which teaches the same idiom for coupon redemption caps.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
17873
+ "readme": '# Event Bookings template\n\nEvents/appointments with **race-proof capacity** \u2014 declared end-to-end in one typed `vxil.config.ts`.\nCapacity here is not a hope or a cron sweep: it is a declarative invariant enforced atomically at write\ntime, so overselling is structurally impossible.\n\n**Provisions:**\n- `events` \u2014 title, unique slug, `starts_at`/`ends_at` (slot-bound for range queries), `capacity`, status.\n- `bookings` \u2014 `event` relation, `member` (the **owner field** \u2014 a booker only sees their own),\n status (`booked | cancelled`, enforced by a Lane-A validate hook), `booked_at`.\n- `auth` (email/password) \u2014 a booker is a verified end-user.\n- `notifications` \u2014 send confirmations from your own code (`mock` provider until you wire a real one).\n\n**Use it:**\n\n```bash\nvxil init --template bookings\nvxil quickstart --env staging\nvxil push\nvxil seed # the demo event ("Launch Workshop", capacity 3) \u2014 push alone doesn\'t seed\nvxil gen\n```\n\n**What to learn from this:**\n1. **Per-key serialization (`lock`).** `lock: "evt:<event_id>"` takes a per-(project,collection,key)\n lock at the start of the write \u2014 every writer of THIS event queues;\n writers of other events don\'t contend.\n2. **Multi-invariant `guards[]`.** Each guard asserts *"after this write, at most `max` live items match\n `filter`"* \u2014 seat capacity AND one-booking-per-member, checked co-atomically under the ONE lock.\n Guards ride the **write request body**, never config (vxil.com/docs/api: the item write routes).\n3. **Exactly one winner.** N concurrent bookings of the last seat: the lock serializes them, the first\n passes both counts and commits (**201**); every later one counts a full event and gets **409 `guard_failed`**\n with your `message` ("event is full"). Cancelling (PATCH `status` \u2192 `\'cancelled\'`) frees the seat,\n since the guard filters only count `status: \'booked\'` rows.\n\nThe canonical oversell-proof write (curl; `$EVENT_ID` = the seeded event, capacity 3 \u2014 note the\ntop-level `"status"` is the draft/published **lifecycle**, distinct from the booking\'s own `status` field):\n\n```bash\ncurl -s -X POST "$EDGE/v1/cms/items/bookings" \\\n -H "Authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d \'{\n "data": { "event": "\'$EVENT_ID\'", "member": "\'$ME\'", "status": "booked", "booked_at": "2026-08-01T17:00:00Z" },\n "status": "published",\n "lock": "evt:\'$EVENT_ID\'",\n "guards": [\n { "filter": { "event": "\'$EVENT_ID\'", "status": "booked" }, "max": 3, "message": "event is full" },\n { "filter": { "event": "\'$EVENT_ID\'", "member": "\'$ME\'", "status": "booked" }, "max": 1, "message": "already booked" }\n ]\n }\'\n```\n\nOr the typed SDK \u2014 `vx.from(...).create` takes `lock`/`guards` as write options:\n\n```ts\nconst { item_id } = await vx.from(\'bookings\').create(\n { event: eventId, member: me, status: \'booked\', booked_at: new Date().toISOString() },\n {\n status: \'published\',\n lock: `evt:${eventId}`,\n guards: [\n { filter: { event: eventId, status: \'booked\' }, max: capacity, message: \'event is full\' },\n { filter: { event: eventId, member: me, status: \'booked\' }, max: 1, message: \'already booked\' },\n ],\n },\n);\n```\n\n(`max` is the event\'s `capacity` \u2014 read the event row first; the guard re-counts atomically at write\ntime, so a stale seat COUNT can never oversell. A concurrently edited `capacity` is still read\npre-lock \u2014 pass the freshest value you have.)\n\n**Go deeper:** vxil.com/docs/api (lock/guard/guards on the item write routes), vxil.com/docs/guide/07-validation-and-hooks (Lane-A hooks), vxil.com/docs/guide/04-data-with-cms (owner-scoping);\nvxil.com/docs/guide/06-feature-catalog (auth); `examples/ecommerce/`, which teaches the same idiom for coupon redemption caps.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
18110
17874
  "functions": {}
18111
17875
  },
18112
17876
  {
@@ -18126,123 +17890,11 @@ export default defineConfig({
18126
17890
  ],
18127
17891
  "hasFunctions": true,
18128
17892
  "byoKeys": [],
18129
- "configSrc": `import { defineConfig } from '@vxil/config';
18130
-
18131
- // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
18132
- // "Helpdesk" \u2014 a support-ticketing backend (tickets + threaded messages),
18133
- // declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014
18134
- // \u2022 cms \u2192 tickets \u2192 ticket_messages (resolved by relation), with a
18135
- // Lane-A STATE-MACHINE hook guarding ticket.status
18136
- // \u2022 auth \u2192 requesters sign in as end-users; tickets are owner-scoped
18137
- // \u2022 notifications \u2192 the acknowledgement message on every new ticket
18138
- // \u2022 functions \u2192 the async side: the ack cms-hook + the hourly SLA sweep
18139
- // Everything here is DATA the tenant owns and edits after \`vxil init\`.
18140
- // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
18141
- export default defineConfig({
18142
- env: 'staging',
18143
-
18144
- features: {
18145
- cms: {
18146
- hooks: {
18147
- // The TICKET STATE MACHINE: reject any illegal status transition,
18148
- // atomically, in the same write (\`closed\` is terminal). Async
18149
- // side-effects (the ack send, the SLA sweep) live in functions \u2014
18150
- // a Lane-A hook is a pure expression and can't do I/O.
18151
- ticket_transition: {
18152
- collection: 'tickets',
18153
- event: 'beforeUpdate',
18154
- kind: 'validate',
18155
- expr:
18156
- 'item.status == before.status' +
18157
- " || (before.status == 'open' && (item.status == 'pending' || item.status == 'resolved'))" +
18158
- " || (before.status == 'pending' && (item.status == 'open' || item.status == 'resolved'))" +
18159
- " || (before.status == 'resolved' && item.status == 'closed')",
18160
- message: 'illegal ticket status transition',
18161
- },
18162
- },
18163
- },
18164
- auth: { methods: { emailPassword: true } }, // requesters are verified end-users
18165
- notifications: { provider: 'mock', fromEmail: 'support@helpdesk.example' },
18166
- functions: { enabled: true },
18167
- },
18168
-
18169
- // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500
18170
- cms: {
18171
- collections: {
18172
- tickets: {
18173
- singular: 'ticket',
18174
- // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a signed-in requester
18175
- // reads/edits ONLY their own tickets \u2014 one declarative flag, a no-op for
18176
- // server callers (your agent backend sees the whole queue).
18177
- ownerField: 'requester',
18178
- fields: {
18179
- subject: { type: 'string', required: true, indexSlot: 's1' },
18180
- status: { type: 'string', indexSlot: 's2' }, // open | pending | resolved | closed (hook-guarded)
18181
- priority: { type: 'string', indexSlot: 's3' }, // low | normal | high | urgent
18182
- requester: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id
18183
- opened_at: { type: 'datetime', indexSlot: 't1' },
18184
- sla_due: { type: 'datetime', indexSlot: 't2' }, // the sla-sweep cron's range filter
18185
- body: { type: 'text' },
18186
- },
18187
- },
18188
- ticket_messages: {
18189
- singular: 'ticket_message',
18190
- fields: {
18191
- ticket: { type: 'relation', relationTo: 'tickets', indexSlot: 's1' },
18192
- author: { type: 'string', indexSlot: 's2' }, // requester or agent id
18193
- body: { type: 'text' },
18194
- sent_at: { type: 'datetime', indexSlot: 't1' },
18195
- },
18196
- },
18197
- },
18198
- },
18199
-
18200
- // \u2500\u2500 The domain logic that ISN'T config: tenant functions \u2500\u2500
18201
- functions: {
18202
- // Acknowledgement: on ticket CREATE, RE-FETCH the ticket by id (the cms-hook
18203
- // payload carries only { event, collection, item_id }) and send the requester
18204
- // an acknowledgement through notifications.
18205
- 'on-ticket-created': {
18206
- entry: './functions/on-ticket-created.ts',
18207
- trigger: { kind: 'cmsHook', collection: 'tickets', event: 'beforeCreate' },
18208
- scopes: ['cms:read', 'notifications:send'],
18209
- egressAllow: [], // pure vxil-internal; no external egress needed
18210
- },
18211
- // SLA automation: hourly sweep of breached tickets (sla_due < now, still
18212
- // open/pending, not yet urgent) \u2192 PATCH priority to 'urgent'. Idempotent
18213
- // by construction \u2014 an escalated ticket falls out of the filter.
18214
- 'sla-sweep': {
18215
- entry: './functions/sla-sweep.ts',
18216
- trigger: { kind: 'cron', schedule: '0 * * * *' },
18217
- scopes: ['cms:read', 'cms:write'],
18218
- egressAllow: [],
18219
- },
18220
- },
18221
-
18222
- seed: {
18223
- cms: [
18224
- {
18225
- collection: 'tickets',
18226
- items: [
18227
- {
18228
- subject: 'Cannot sign in on mobile',
18229
- status: 'open',
18230
- priority: 'normal',
18231
- requester: 'user_demo',
18232
- opened_at: '2026-07-01T09:00:00Z',
18233
- sla_due: '2026-07-01T17:00:00Z',
18234
- body: 'Login on the iOS app spins forever after entering credentials.',
18235
- },
18236
- ],
18237
- },
18238
- ],
18239
- },
18240
- });
18241
- `,
18242
- "readme": "# Helpdesk / Support Ticketing template\n\nA support-ticketing backend \u2014 requester-owned tickets with a hook-enforced status state machine,\nthreaded conversation messages, an acknowledgement send on every new ticket, and an hourly\nSLA-escalation cron \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**What it provisions:**\n- `tickets` \u2014 subject, `status` (state machine below), priority, `requester` (the **owner field**),\n `opened_at`, `sla_due`, body. Every queue-driving field is slot-indexed for filter/sort.\n- `ticket_messages` \u2014 the conversation thread: `ticket` relation, author, body, `sent_at`.\n- Features: `cms` + `auth` (email/password end-users) + `notifications` (mock provider) + `functions`.\n- Functions: `on-ticket-created` (cmsHook \u2192 acknowledgement send) and `sla-sweep` (hourly cron).\n\n**Apply it:**\n\n```bash\nvxil init --template helpdesk\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **A status state machine in a Lane-A hook** \u2014 the `beforeUpdate` validate allows only\n `open\u2192pending|resolved`, `pending\u2192open|resolved`, `resolved\u2192closed`; any other transition is a\n clean 422, atomically, in the write itself (vxil.com/docs/guide/07-validation-and-hooks).\n- **SLA automation as a cron function** \u2014 `sla-sweep` queries breached tickets with the \xA73 filter DSL\n (`status $in` + `sla_due $lt`, slot-indexed) and PATCHes `priority: 'urgent'`; the `$ne: 'urgent'`\n term makes re-runs idempotent.\n- **Requester-scoped end-user access** \u2014 `tickets.ownerField = 'requester'`: a signed-in requester\n sees and edits only their **own** tickets (vxil.com/docs/guide/04-data-with-cms); server keys see the queue.\n\n```ts\nconst { item_id } = await vx.from('tickets').create({\n subject: 'Cannot sign in on mobile', status: 'open', priority: 'normal',\n requester: 'user_demo', opened_at: new Date().toISOString(),\n sla_due: new Date(Date.now() + 8 * 3600e3).toISOString(), body: 'Steps to reproduce\u2026',\n});\n\n// the agent queue, most-overdue first (slot-indexed \u2192 typed filter/sort, index-served)\nconst { items } = await vx.from('tickets').query({\n filter: { status: { $in: ['open', 'pending'] } }, sort: 'sla_due', limit: 25,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 owner-scoping) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/08-running-your-code-functions \xB7 vxil.com/docs/guide/06-feature-catalog (notifications) \xB7 `examples/ecommerce/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
17893
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Helpdesk\" \u2014 a support-ticketing backend (tickets + threaded messages),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 tickets \u2192 ticket_messages (resolved by relation), with a\n// Lane-A STATE-MACHINE hook guarding ticket.status\n// \u2022 auth \u2192 requesters sign in as end-users; tickets are owner-scoped\n// \u2022 notifications \u2192 the acknowledgement message on every new ticket\n// \u2022 functions \u2192 the async side: the ack cms-hook + the hourly SLA sweep\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Fail-safe owner scoping: a signed-in requester may only touch collections\n // with an `ownerField`. `tickets` has one; `ticket_messages` does not, so\n // without this flag every requester could read every thread. With it, the\n // thread is server-only for end users (your agent backend writes and reads\n // it) \u2014 give it an owner field if requesters should post replies themselves.\n strictEndUserScope: true,\n hooks: {\n // The TICKET STATE MACHINE: reject any illegal status transition,\n // atomically, in the same write (`closed` is terminal). Async\n // side-effects (the ack send, the SLA sweep) live in functions \u2014\n // a Lane-A hook is a pure expression and can't do I/O.\n ticket_transition: {\n collection: 'tickets',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.status == before.status' +\n \" || (before.status == 'open' && (item.status == 'pending' || item.status == 'resolved'))\" +\n \" || (before.status == 'pending' && (item.status == 'open' || item.status == 'resolved'))\" +\n \" || (before.status == 'resolved' && item.status == 'closed')\",\n message: 'illegal ticket status transition',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // requesters are verified end-users\n notifications: { provider: 'mock', fromEmail: 'support@helpdesk.example' },\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n tickets: {\n singular: 'ticket',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a signed-in requester\n // reads/edits ONLY their own tickets \u2014 one declarative flag, a no-op for\n // server callers (your agent backend sees the whole queue).\n ownerField: 'requester',\n fields: {\n subject: { type: 'string', required: true, indexSlot: 's1' },\n status: { type: 'string', indexSlot: 's2' }, // open | pending | resolved | closed (hook-guarded)\n priority: { type: 'string', indexSlot: 's3' }, // low | normal | high | urgent\n requester: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id\n opened_at: { type: 'datetime', indexSlot: 't1' },\n sla_due: { type: 'datetime', indexSlot: 't2' }, // the sla-sweep cron's range filter\n body: { type: 'text' },\n },\n },\n ticket_messages: {\n singular: 'ticket_message',\n fields: {\n ticket: { type: 'relation', relationTo: 'tickets', indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' }, // requester or agent id\n body: { type: 'text' },\n sent_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions \u2500\u2500\n functions: {\n // Acknowledgement: on ticket CREATE, RE-FETCH the ticket by id (the cms-hook\n // payload carries only { event, collection, item_id }) and send the requester\n // an acknowledgement through notifications.\n 'on-ticket-created': {\n entry: './functions/on-ticket-created.ts',\n trigger: { kind: 'cmsHook', collection: 'tickets', event: 'beforeCreate' },\n scopes: ['cms:read', 'notifications:send'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // SLA automation: hourly sweep of breached tickets (sla_due < now, still\n // open/pending, not yet urgent) \u2192 PATCH priority to 'urgent'. Idempotent\n // by construction \u2014 an escalated ticket falls out of the filter.\n 'sla-sweep': {\n entry: './functions/sla-sweep.ts',\n trigger: { kind: 'cron', schedule: '0 * * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'tickets',\n items: [\n {\n subject: 'Cannot sign in on mobile',\n status: 'open',\n priority: 'normal',\n requester: 'user_demo',\n opened_at: '2026-07-01T09:00:00Z',\n sla_due: '2026-07-01T17:00:00Z',\n body: 'Login on the iOS app spins forever after entering credentials.',\n },\n ],\n },\n ],\n },\n});\n",
17894
+ "readme": "# Helpdesk / Support Ticketing template\n\nA support-ticketing backend \u2014 requester-owned tickets with a hook-enforced status state machine,\nthreaded conversation messages, an acknowledgement send on every new ticket, and an hourly\nSLA-escalation cron \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**What it provisions:**\n- `tickets` \u2014 subject, `status` (state machine below), priority, `requester` (the **owner field**),\n `opened_at`, `sla_due`, body. Every queue-driving field is slot-indexed for filter/sort.\n- `ticket_messages` \u2014 the conversation thread: `ticket` relation, author, body, `sent_at`.\n- Features: `cms` + `auth` (email/password end-users) + `notifications` (mock provider) + `functions`.\n- Functions: `on-ticket-created` (cmsHook \u2192 acknowledgement send) and `sla-sweep` (hourly cron).\n\n**Apply it:**\n\n```bash\nvxil init --template helpdesk\nvxil quickstart --env staging --no-push\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push\nvxil gen\n```\n\n> **Plan note.** The two functions deploy on Free when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n- **A status state machine in a Lane-A hook** \u2014 the `beforeUpdate` validate allows only\n `open\u2192pending|resolved`, `pending\u2192open|resolved`, `resolved\u2192closed`; any other transition is a\n clean 422, atomically, in the write itself (vxil.com/docs/guide/07-validation-and-hooks).\n- **SLA automation as a cron function** \u2014 `sla-sweep` queries breached tickets with the filter DSL (guide ch. 4, querying)\n (`status $in` + `sla_due $lt`, slot-indexed) and PATCHes `priority: 'urgent'`; the `$ne: 'urgent'`\n term makes re-runs idempotent.\n- **Requester-scoped end-user access** \u2014 `tickets.ownerField = 'requester'`: a signed-in requester\n sees and edits only their **own** tickets (vxil.com/docs/guide/04-data-with-cms); server keys see the queue.\n- **Fail-safe scoping with `strictEndUserScope: true`** \u2014 `ticket_messages` has no owner field, so\n without the flag every signed-in requester could read every thread. With it, an end-user session gets\n `403 server_only` on any collection without an `ownerField`: the thread is written and read by your\n agent backend (server key). Give `ticket_messages` an owner field if requesters should post replies.\n\n```ts\nconst { item_id } = await vx.from('tickets').create({\n subject: 'Cannot sign in on mobile', status: 'open', priority: 'normal',\n requester: 'user_demo', opened_at: new Date().toISOString(),\n sla_due: new Date(Date.now() + 8 * 3600e3).toISOString(), body: 'Steps to reproduce\u2026',\n});\n\n// the agent queue, most-overdue first (slot-indexed \u2192 typed filter/sort, index-served)\nconst { items } = await vx.from('tickets').query({\n filter: { status: { $in: ['open', 'pending'] } }, sort: 'sla_due', limit: 25,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 owner-scoping) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/08-running-your-code-functions \xB7 vxil.com/docs/guide/06-feature-catalog (notifications) \xB7 `examples/ecommerce/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
18243
17895
  "functions": {
18244
- "on-ticket-created.ts": "// on-ticket-created.ts \u2014 the ACKNOWLEDGEMENT hook (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `tickets`. On a CREATE, send the\n// requester an acknowledgement through notifications. The cms-hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// ticket by id (through the edge, tenant-scoped) rather than trusting inline fields.\n// Delivery is at-least-once: the envelope idempotency_key rides the send as its\n// Idempotency-Key header, so a redelivered hook never double-sends.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface TicketData { subject?: string; status?: string; requester?: string; sla_due?: string }\ninterface Item { data?: { data?: TicketData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'tickets' || !cms || !notif || !itemId) {\n return Response.json({ skipped: true });\n }\n // acknowledge only the CREATE (the cms.item.* subscription also delivers updates)\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n // Re-fetch the ticket (the payload carries only the id).\n const res = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const ticket = ((await res.json()) as Item).data?.data ?? {};\n if (!ticket.requester) return Response.json({ skipped: true, reason: 'no requester' });\n\n // Acknowledge to the requester (email/inbox via the configured provider).\n const send = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n ...(env.idempotency_key ? { 'idempotency-key': env.idempotency_key } : {}),\n },\n body: JSON.stringify({\n user_id: ticket.requester,\n template: 'transactional',\n data: {\n subject: `We got your ticket: ${ticket.subject ?? itemId}`,\n paragraph:\n `Your ticket is ${ticket.status ?? 'open'} and in our queue` +\n `${ticket.sla_due ? ` (response due by ${ticket.sla_due})` : ''}. ` +\n 'Reply in the app to add details.',\n },\n }),\n });\n return Response.json({ acknowledged: itemId, delivery: send.status });\n },\n};\n",
18245
- "sla-sweep.ts": "// sla-sweep.ts \u2014 SLA ESCALATION CRON (a vxil function, \xA77.3).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep tickets whose sla_due has passed and\n// that are still open/pending \u2014 the cms filter DSL (vxil.com/docs/guide/04-data-with-cms):\n// `status $in` on the s2 slot, `sla_due $lt` on the t2 slot, `priority $ne` on\n// s3 \u2014 all index-served. Each breach is escalated with a PATCH to priority\n// 'urgent'; the $ne term makes re-runs idempotent (an escalated ticket falls out\n// of the filter). The Lane-A state-machine hook still runs on every PATCH; a\n// priority-only write keeps item.status == before.status, so it always passes.\n\n// cron-walk: drains-filter \u2014 an escalated ticket gets priority 'urgent' and leaves the `$ne` filter.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Ticket { item_id: string; data: { subject?: string; priority?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // breached = still open/pending, past its sla_due, not yet urgent\n const filter = enc({\n status: { $in: ['open', 'pending'] },\n sla_due: { $lt: new Date().toISOString() },\n priority: { $ne: 'urgent' },\n });\n const res = await fetch(`${base}/v1/cms/items/tickets?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: Ticket[] } };\n const breached = body.data?.items ?? [];\n\n let escalated = 0;\n for (const t of breached) {\n const r = await fetch(`${base}/v1/cms/items/tickets/${t.item_id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { priority: 'urgent' } }),\n });\n if (r.ok) escalated++;\n }\n return Response.json({ scanned: breached.length, escalated });\n },\n};\n\n// \u2500\u2500 tiny helper (the vxil REST list envelope is { data: { items } }) \u2500\u2500\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
17896
+ "on-ticket-created.ts": "// on-ticket-created.ts \u2014 the ACKNOWLEDGEMENT hook (a vxil function).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `tickets`. On a CREATE, send the\n// requester an acknowledgement through notifications. The cms-hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// ticket by id (through the edge, tenant-scoped) rather than trusting inline fields.\n// Delivery is at-least-once: the envelope idempotency_key rides the send as its\n// Idempotency-Key header, so a redelivered hook never double-sends.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface TicketData { subject?: string; status?: string; requester?: string; sla_due?: string }\ninterface Item { data?: { data?: TicketData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'tickets' || !cms || !notif || !itemId) {\n return Response.json({ skipped: true });\n }\n // acknowledge only the CREATE (the cms.item.* subscription also delivers updates)\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n // Re-fetch the ticket (the payload carries only the id).\n const res = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const ticket = ((await res.json()) as Item).data?.data ?? {};\n if (!ticket.requester) return Response.json({ skipped: true, reason: 'no requester' });\n\n // Acknowledge to the requester (email/inbox via the configured provider).\n const send = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n ...(env.idempotency_key ? { 'idempotency-key': env.idempotency_key } : {}),\n },\n body: JSON.stringify({\n user_id: ticket.requester,\n template: 'transactional',\n data: {\n subject: `We got your ticket: ${ticket.subject ?? itemId}`,\n paragraph:\n `Your ticket is ${ticket.status ?? 'open'} and in our queue` +\n `${ticket.sla_due ? ` (response due by ${ticket.sla_due})` : ''}. ` +\n 'Reply in the app to add details.',\n },\n }),\n });\n return Response.json({ acknowledged: itemId, delivery: send.status });\n },\n};\n",
17897
+ "sla-sweep.ts": "// sla-sweep.ts \u2014 SLA ESCALATION CRON (a vxil function).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep tickets whose sla_due has passed and\n// that are still open/pending \u2014 the cms filter DSL (vxil.com/docs/guide/04-data-with-cms):\n// `status $in` on the s2 slot, `sla_due $lt` on the t2 slot, `priority $ne` on\n// s3 \u2014 all index-served. Each breach is escalated with a PATCH to priority\n// 'urgent'; the $ne term makes re-runs idempotent (an escalated ticket falls out\n// of the filter). The Lane-A state-machine hook still runs on every PATCH; a\n// priority-only write keeps item.status == before.status, so it always passes.\n\n// cron-walk: drains-filter \u2014 an escalated ticket gets priority 'urgent' and leaves the `$ne` filter.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Ticket { item_id: string; data: { subject?: string; priority?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // breached = still open/pending, past its sla_due, not yet urgent\n const filter = enc({\n status: { $in: ['open', 'pending'] },\n sla_due: { $lt: new Date().toISOString() },\n priority: { $ne: 'urgent' },\n });\n const res = await fetch(`${base}/v1/cms/items/tickets?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: Ticket[] } };\n const breached = body.data?.items ?? [];\n\n let escalated = 0;\n for (const t of breached) {\n const r = await fetch(`${base}/v1/cms/items/tickets/${t.item_id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { priority: 'urgent' } }),\n });\n if (r.ok) escalated++;\n }\n return Response.json({ scanned: breached.length, escalated });\n },\n};\n\n// \u2500\u2500 tiny helper (the vxil REST list envelope is { data: { items } }) \u2500\u2500\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
18246
17898
  }
18247
17899
  },
18248
17900
  {
@@ -18262,8 +17914,8 @@ export default defineConfig({
18262
17914
  ],
18263
17915
  "hasFunctions": false,
18264
17916
  "byoKeys": [],
18265
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Sales CRM\" \u2014 companies \u2192 contacts \u2192 a deal pipeline, declared end-to-end in\n// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 companies \u2192 contacts \u2192 deals (resolved by relation), with a\n// Lane-A STATE MACHINE guarding the pipeline stage\n// \u2022 activity-feed \u2192 the per-deal timeline (calls, emails, notes) + the rep bell\n// \u2022 notifications \u2192 the email channel (mock provider until you bring a key)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // THE PIPELINE STATE MACHINE (Lane-A validate \u2014 runs inside the write\n // transaction): a deal may only move lead \u2192 qualified \u2192 proposal \u2192\n // won | lost. Any other transition is a clean 422, rolled back\n // atomically. Cross-row work (e.g. \"email the rep on won\") belongs in\n // a function or webhook, never in a hook \u2014 hooks are pure row logic.\n deal_stage_machine: {\n collection: 'deals',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.stage == before.stage' +\n \" || (before.stage == 'lead' && item.stage == 'qualified')\" +\n \" || (before.stage == 'qualified' && item.stage == 'proposal')\" +\n \" || (before.stage == 'proposal' && (item.stage == 'won' || item.stage == 'lost'))\",\n message: 'illegal pipeline transition (lead \u2192 qualified \u2192 proposal \u2192 won|lost)',\n },\n },\n },\n\n // The per-deal timeline. The DEFAULT feed groups already fit a CRM:\n // `timeline` (flat, chronological) keyed per deal \u2014 ingest with\n // POST /v1/feeds/timeline/{deal_item_id}/activities { actor, verb, object }\n // \u2014 and the aggregated `notification` bell per rep.\n 'activity-feed': {},\n\n // Email channel. `mock` is the zero-config staging provider; for real\n // sends switch to provider: 'resend' and set `resendApiKeyRef`, or to\n // provider: 'ses' with `ses: { region, accessKeyIdRef, secretAccessKeyRef }`.\n notifications: { provider: 'mock', fromEmail: 'noreply@crm.app' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n companies: {\n singular: 'company',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // Want `domain` unique? Declare `unique: true` right here \u2014 value\n // uniqueness (cms.md \xA79.3) is carried by `vxil push` on collection/field\n // CREATE. Flipping it on an ALREADY-pushed field is an in-place alter\n // via the dashboard field designer or a same-name+type re-add through\n // POST /v1/cms/collections/companies/fields (push diffs name+type only).\n domain: { type: 'string', indexSlot: 's2' },\n industry: { type: 'string', indexSlot: 's3' },\n employees: { type: 'int', indexSlot: 'n1' },\n },\n },\n contacts: {\n singular: 'contact',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n email: { type: 'string', indexSlot: 's2' }, // unique \u21D2 see the `domain` note above\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n last_touch: { type: 'datetime', indexSlot: 't1' },\n phone: { type: 'string' }, // stored, not indexed\n },\n },\n deals: {\n singular: 'deal',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // The pipeline stage: enum-validated on every write; the Lane-A hook\n // above additionally locks the TRANSITIONS between stages.\n stage: {\n type: 'string',\n required: true,\n indexSlot: 's2',\n validation: { enum: ['lead', 'qualified', 'proposal', 'won', 'lost'] },\n },\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n owner: { type: 'string', indexSlot: 's4' }, // the rep working the deal\n amount_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n close_date: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n },\n },\n\n // `vxil seed` POSTs each item verbatim \u2014 item_ids are assigned at runtime, so\n // the `company` relation on contacts/deals is left unset here; link records\n // after seeding (PATCH the created items with the company's item_id).\n seed: {\n cms: [\n {\n collection: 'companies',\n items: [{ name: 'Acme Corp', domain: 'acme.com', industry: 'Manufacturing', employees: 250 }],\n },\n {\n collection: 'contacts',\n items: [{ name: 'Jane Porter', email: 'jane@acme.com', phone: '+1 555 0100' }],\n },\n {\n collection: 'deals',\n items: [\n { title: 'Acme starter plan', stage: 'lead', owner: 'jordan', amount_cents: 480_000 },\n { title: 'Acme enterprise rollout', stage: 'proposal', owner: 'sam', amount_cents: 12_000_000 },\n ],\n },\n ],\n },\n});\n",
18266
- "readme": '# Sales CRM template\n\nA sales CRM backend \u2014 companies, contacts, and a deal pipeline \u2014 declared end-to-end in one typed\n`vxil.config.ts`. Stage transitions are guarded by a Lane-A state machine that runs inside the write\ntransaction, every deal gets an activity timeline via `activity-feed`, and `notifications` provides\nthe email channel (mock provider until you bring your own key).\n\n**What it provisions:**\n- `companies` \u2014 name, domain, industry, employee count (slot-bound for range/sort). To make\n `domain`/`email` unique, declare `unique: true` on the field \u2014 carried by `vxil push` on create\n (`cms.md` \xA79.3); flipping it on an already-pushed field is a dashboard/REST in-place alter.\n- `contacts` \u2014 name, email, `company` relation, `last_touch` datetime.\n- `deals` \u2014 title, enum-validated `stage`, `company` relation, `owner` (the rep), `amount_cents`,\n `close_date`. A Lane-A `validate` hook locks the pipeline: `lead \u2192 qualified \u2192 proposal \u2192 won|lost`.\n- Features: `cms` + `activity-feed` (per-deal timelines + the rep notification bell, default feed\n groups) + `notifications` (mock provider; swap to `resend` + `resendApiKeyRef`, or `ses` +\n `ses: { region, accessKeyIdRef, secretAccessKeyRef }`, for real sends).\n\n**Apply it:**\n\n```bash\nvxil init --template crm\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen # then optionally: `vxil seed` (1 company, 1 contact, 2 deals)\n```\n\n**What to learn from this:**\n1. **The pipeline state machine** \u2014 a `validate` hook on `beforeUpdate` compares `item.stage` to\n `before.stage` and rejects any illegal transition with a clean 422, atomically in the same write.\n Field-level `validation.enum` handles membership; the hook handles the *transitions*.\n2. **Pipeline value by stage** \u2014 one bounded group-by aggregate (`stage` is slot-bound, `amount_cents`\n is an `n*` slot), no SQL:\n ```bash\n curl -X POST https://api.vxil.com/v1/cms/items/deals/aggregate \\\n -H "Authorization: Bearer $VXIL_KEY" -H \'content-type: application/json\' \\\n -d \'{"aggregates":[{"fn":"sum","field":"amount_cents","as":"pipeline_cents"},{"fn":"count"}],"groupBy":["stage"]}\'\n # \u2192 { "groups": [{ "key": {"stage":"proposal"}, "count": 1, "pipeline_cents": 12000000 }, \u2026], "scanned": \u2026 }\n ```\n3. **The deal timeline** \u2014 each call/email/note is one ingest to the default `timeline` feed group:\n `POST /v1/feeds/timeline/{deal_item_id}/activities` with `{ actor, verb, object }` (idempotent on\n `foreign_id` + `time`); read it back with keyset cursors, or typed via `vx.from(\'deals\').query(\u2026)`\n for the pipeline board itself.\n\n**Go deeper:** vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (aggregates), vxil.com/docs/guide/06-feature-catalog (activity-feed,\nnotifications), and `examples/ecommerce/` for a bigger state machine in the wild.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
17917
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Sales CRM\" \u2014 companies \u2192 contacts \u2192 a deal pipeline, declared end-to-end in\n// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 companies \u2192 contacts \u2192 deals (resolved by relation), with a\n// Lane-A STATE MACHINE guarding the pipeline stage\n// \u2022 activity-feed \u2192 the per-deal timeline (calls, emails, notes) + the rep bell\n// \u2022 notifications \u2192 the email channel (mock provider until you bring a key)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // THE PIPELINE STATE MACHINE (Lane-A validate \u2014 runs inside the write\n // transaction): a deal may only move lead \u2192 qualified \u2192 proposal \u2192\n // won | lost. Any other transition is a clean 422, rolled back\n // atomically. Cross-row work (e.g. \"email the rep on won\") belongs in\n // a function or webhook, never in a hook \u2014 hooks are pure row logic.\n deal_stage_machine: {\n collection: 'deals',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.stage == before.stage' +\n \" || (before.stage == 'lead' && item.stage == 'qualified')\" +\n \" || (before.stage == 'qualified' && item.stage == 'proposal')\" +\n \" || (before.stage == 'proposal' && (item.stage == 'won' || item.stage == 'lost'))\",\n message: 'illegal pipeline transition (lead \u2192 qualified \u2192 proposal \u2192 won|lost)',\n },\n },\n },\n\n // The per-deal timeline. The DEFAULT feed groups already fit a CRM:\n // `timeline` (flat, chronological) keyed per deal \u2014 ingest with\n // POST /v1/feeds/timeline/{deal_item_id}/activities { actor, verb, object }\n // \u2014 and the aggregated `notification` bell per rep.\n 'activity-feed': {},\n\n // Email channel. `mock` is the zero-config staging provider; for real\n // sends switch to provider: 'resend' and set `resendApiKeyRef`, or to\n // provider: 'ses' with `ses: { region, accessKeyIdRef, secretAccessKeyRef }`.\n notifications: { provider: 'mock', fromEmail: 'noreply@crm.app' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n companies: {\n singular: 'company',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // Want `domain` unique? Declare `unique: true` right here \u2014 value\n // uniqueness (guide ch. 4, uniqueness) is carried by `vxil push` on collection/field\n // CREATE. Flipping it on an ALREADY-pushed field is an in-place alter\n // via the dashboard field designer or a same-name+type re-add through\n // POST /v1/cms/collections/companies/fields (push diffs name+type only).\n domain: { type: 'string', indexSlot: 's2' },\n industry: { type: 'string', indexSlot: 's3' },\n employees: { type: 'int', indexSlot: 'n1' },\n },\n },\n contacts: {\n singular: 'contact',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n email: { type: 'string', indexSlot: 's2' }, // unique \u21D2 see the `domain` note above\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n last_touch: { type: 'datetime', indexSlot: 't1' },\n phone: { type: 'string' }, // stored, not indexed\n },\n },\n deals: {\n singular: 'deal',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // The pipeline stage: enum-validated on every write; the Lane-A hook\n // above additionally locks the TRANSITIONS between stages.\n stage: {\n type: 'string',\n required: true,\n indexSlot: 's2',\n validation: { enum: ['lead', 'qualified', 'proposal', 'won', 'lost'] },\n },\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n owner: { type: 'string', indexSlot: 's4' }, // the rep working the deal\n amount_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n close_date: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n },\n },\n\n // `vxil seed` POSTs each item verbatim \u2014 item_ids are assigned at runtime, so\n // the `company` relation on contacts/deals is left unset here; link records\n // after seeding (PATCH the created items with the company's item_id).\n seed: {\n cms: [\n {\n collection: 'companies',\n items: [{ name: 'Acme Corp', domain: 'acme.com', industry: 'Manufacturing', employees: 250 }],\n },\n {\n collection: 'contacts',\n items: [{ name: 'Jane Porter', email: 'jane@acme.com', phone: '+1 555 0100' }],\n },\n {\n collection: 'deals',\n items: [\n { title: 'Acme starter plan', stage: 'lead', owner: 'jordan', amount_cents: 480_000 },\n { title: 'Acme enterprise rollout', stage: 'proposal', owner: 'sam', amount_cents: 12_000_000 },\n ],\n },\n ],\n },\n});\n",
17918
+ "readme": '# Sales CRM template\n\nA sales CRM backend \u2014 companies, contacts, and a deal pipeline \u2014 declared end-to-end in one typed\n`vxil.config.ts`. Stage transitions are guarded by a Lane-A state machine that runs inside the write\ntransaction, every deal gets an activity timeline via `activity-feed`, and `notifications` provides\nthe email channel (mock provider until you bring your own key).\n\n**What it provisions:**\n- `companies` \u2014 name, domain, industry, employee count (slot-bound for range/sort). To make\n `domain`/`email` unique, declare `unique: true` on the field \u2014 carried by `vxil push` on create\n (guide ch. 4, uniqueness); flipping it on an already-pushed field is a dashboard/REST in-place alter.\n- `contacts` \u2014 name, email, `company` relation, `last_touch` datetime.\n- `deals` \u2014 title, enum-validated `stage`, `company` relation, `owner` (the rep), `amount_cents`,\n `close_date`. A Lane-A `validate` hook locks the pipeline: `lead \u2192 qualified \u2192 proposal \u2192 won|lost`.\n- Features: `cms` + `activity-feed` (per-deal timelines + the rep notification bell, default feed\n groups) + `notifications` (mock provider; swap to `resend` + `resendApiKeyRef`, or `ses` +\n `ses: { region, accessKeyIdRef, secretAccessKeyRef }`, for real sends).\n\n**Apply it:**\n\n```bash\nvxil init --template crm\nvxil quickstart --env staging # or `vxil link` to an existing tenant\nvxil push\nvxil gen # then optionally: `vxil seed` (1 company, 1 contact, 2 deals)\n```\n\n**What to learn from this:**\n1. **The pipeline state machine** \u2014 a `validate` hook on `beforeUpdate` compares `item.stage` to\n `before.stage` and rejects any illegal transition with a clean 422, atomically in the same write.\n Field-level `validation.enum` handles membership; the hook handles the *transitions*.\n2. **Pipeline value by stage** \u2014 one bounded group-by aggregate (`stage` is slot-bound, `amount_cents`\n is an `n*` slot), no SQL:\n ```bash\n curl -X POST https://api.vxil.com/v1/cms/items/deals/aggregate \\\n -H "Authorization: Bearer $VXIL_KEY" -H \'content-type: application/json\' \\\n -d \'{"aggregates":[{"fn":"sum","field":"amount_cents","as":"pipeline_cents"},{"fn":"count"}],"groupBy":["stage"]}\'\n # \u2192 { "groups": [{ "key": {"stage":"proposal"}, "count": 1, "pipeline_cents": 12000000 }, \u2026], "scanned": \u2026 }\n ```\n3. **The deal timeline** \u2014 each call/email/note is one ingest to the default `timeline` feed group:\n `POST /v1/feeds/timeline/{deal_item_id}/activities` with `{ actor, verb, object }` (idempotent on\n `foreign_id` + `time`); read it back with keyset cursors, or typed via `vx.from(\'deals\').query(\u2026)`\n for the pipeline board itself.\n\n**Go deeper:** vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (aggregates), vxil.com/docs/guide/06-feature-catalog (activity-feed,\nnotifications), and `examples/ecommerce/` for a bigger state machine in the wild.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
18267
17919
  "functions": {}
18268
17920
  },
18269
17921
  {
@@ -18286,9 +17938,9 @@ export default defineConfig({
18286
17938
  "oidc_client_secret"
18287
17939
  ],
18288
17940
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Team Workspace\" \u2014 the ENTERPRISE blueprint: many companies inside one\n// backend, each with its own members and roles, signing in through the\n// company's own identity provider, reading a document set where ONE field is\n// visible only to finance. Declared end-to-end in ONE typed file.\n//\n// \u2022 orgs \u2192 organizations + memberships + a tenant-defined `finance` role\n// \u2022 auth \u2192 email/password or magic link today, a generic OIDC issuer as a\n// config swap; account-security controls; a 3-device session cap\n// \u2022 cms \u2192 projects \u2192 documents, owner-scoped, `restrict`-protected, with\n// ONE per-record action button and ONE role-gated field\n// \u2022 functions \u2192 the single step the Archive button runs\n//\n// The one thing that is NOT in this file: inviting a teammate to the vxil\n// PROJECT itself (the dashboard's pending-email invite). That is an operator\n// flow, not app config \u2014 see the README.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n // \u2500\u2500 Workspaces for YOUR customers' teams \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // An organization is a customer company; a membership carries a role. The\n // built-in lattice is owner > admin > member > viewer; `finance` below is a\n // CUSTOM role you define once over the API (see the README) and then assign\n // like any built-in one.\n orgs: {\n enabled: true,\n maxMembersPerOrg: 200,\n invitationTtlHours: 72, // an org invitation token is single-use + TTL-bound\n },\n\n auth: {\n // The demo path: email+password (and magic link) so the walkthrough runs\n // with no identity provider at all.\n methods: { emailPassword: true, magicLink: true },\n\n // \u2500\u2500 SSO: the generic OIDC issuer, as a CONFIG SWAP \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Uncomment this block, store the client secret once, and every member of\n // the workspace signs in through the company's IdP instead. The endpoints\n // and signing keys are discovered from the issuer \u2014 nothing else changes\n // in this file, and no code changes at all. `clientId` is not a secret\n // (it rides every authorize URL); the secret stays a REFERENCE.\n //\n // providers: {\n // oidc: {\n // issuer: 'https://login.example-idp.com', // https, no query/fragment\n // clientId: 'vxil-team-workspace',\n // clientSecretRef: 'secret:oidc_client_secret', // the `secrets` block below\n // scopes: ['email', 'profile'], // `openid` is always added\n // claims: { email: 'email', name: 'name', roles: 'groups' },\n // allowedDomains: ['example.com'], // fail-closed domain fence\n // autoLink: true, // link to a matching verified email\n // },\n // },\n\n // Roles ride the SESSION. With this on, the member's active-org role is\n // embedded in the session at sign-in and refresh, so a read can be gated\n // on it without a round-trip. It is a SNAPSHOT (refreshed with the\n // session) \u2014 use the orgs permission check for revocation-grade calls.\n orgClaims: { enabled: true },\n\n // A member may hold at most three live sessions; a fourth sign-in takes\n // over the oldest (it is revoked, and the sign-in reports which).\n session: { ttlMinutes: 60, refreshTtlDays: 30, maxConcurrent: 3 },\n\n // \u2500\u2500 Account-security controls (all opt-in, all off by default) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n security: {\n // repeated bad passwords on one identifier \u2192 locked, with a retry hint\n lockout: { maxFailures: 5, windowMinutes: 15, lockMinutes: 15 },\n // refuse a sign-up / reset whose password appears in a breach corpus\n breachedPasswords: true,\n // EVERY caller-supplied return URL must match one of these exactly \u2014\n // the anti-open-redirect fence for magic links, resets and SSO returns.\n allowedRedirectOrigins: ['https://app.example.com'],\n // captchaSecretRef: 'turnstile_secret', // add to require a captcha token\n },\n },\n\n cms: {\n hooks: {\n // The DOCUMENT lifecycle, enforced atomically inside the same write.\n // `archived` is terminal; the Archive button below is just the last\n // legal transition, so the button and the API agree by construction.\n document_stage: {\n collection: 'documents',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (before.state == 'draft' && (item.state == 'in_review' || item.state == 'archived'))\"\n + \" || (before.state == 'in_review' && (item.state == 'draft' || item.state == 'approved'))\"\n + \" || (before.state == 'approved' && item.state == 'archived')\",\n message: 'illegal document state transition',\n },\n },\n },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n projects: {\n singular: 'project',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // one project code per workspace \u2014 a duplicate is a clean 409\n code: { type: 'string', unique: true, indexSlot: 's2' },\n stage: { type: 'string', indexSlot: 's3' }, // discovery | active | closed\n created_at: { type: 'datetime', indexSlot: 't1' },\n summary: { type: 'text' },\n },\n },\n\n documents: {\n singular: 'document',\n // Owner-scoping: a signed-in member reads/edits only their OWN\n // documents. A no-op for server callers \u2014 your own backend still sees\n // the whole set.\n ownerField: 'author',\n\n // ONE human-initiated step per record. The dashboard renders a button\n // on every row; pressing it invokes the named function ONCE with\n // { collection, item_id, action, actor, item }. No conditions, no\n // chaining, no scheduling \u2014 the moment it needs branches it is a\n // function of your own, not a button.\n actions: [{ key: 'archive', label: 'Archive', fn: 'archive-document' }],\n\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' }, // the owner (end-user id)\n // `restrict`: while a live document points at a project, deleting\n // that project is REFUSED (409) instead of silently orphaning or\n // cascading. The reverse read (`\u2026/backlinks`) tells you who holds it.\n project: { type: 'relation', relationTo: 'projects', onDelete: 'restrict', indexSlot: 's3' },\n state: { type: 'string', indexSlot: 's4' }, // draft | in_review | approved | archived\n // \u2500\u2500 FIELD-LEVEL READ SECURITY \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Only a signed-in member whose session carries the `finance` role\n // ever receives this field. Everyone else gets the document WITHOUT\n // it \u2014 absent, not null \u2014 and cannot filter or sort on it either, so\n // it can never be read one bit at a time. Your own server key still\n // sees it: this gates END USERS, not you.\n budget_usd: { type: 'int', indexSlot: 'n1', readRoles: ['finance'] },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n },\n },\n },\n },\n\n // \u2500\u2500 The one step that isn't config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n functions: {\n // The Archive button. Invoked through the per-record action route with the\n // pressing member's verified principal carried whole, so the write it makes\n // is owner-scoped exactly as if the member had made it themselves.\n 'archive-document': {\n entry: './functions/archive-document.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // nothing external; it only talks back to your own backend\n },\n },\n\n // References only \u2014 values are stored once and never appear in this file.\n secrets: {\n oidc_client_secret: { feature: 'auth', description: 'OIDC client secret for the workspace identity provider' },\n },\n\n seed: {\n cms: [\n {\n collection: 'projects',\n items: [\n {\n name: 'Northwind Rollout',\n code: 'NW-2026',\n stage: 'active',\n created_at: '2026-01-06T09:00:00Z',\n summary: 'Migration of the Northwind account onto the new platform.',\n },\n ],\n },\n ],\n },\n});\n",
18289
- "readme": '# Team Workspace (saas)\n\nThe **B2B** blueprint: many customer companies inside one backend, each with its own members\nand roles, signing in through the company\'s own identity provider \u2014 and a document set where\none field is visible only to finance.\n\nFive things most "add multi-tenancy to my SaaS" projects end up building by hand, declared here\ninstead: **organizations**, **roles that ride the session**, **SSO as a config swap**,\n**field-level read security**, and **one button per record**.\n\n```bash\nvxil init --template team-workspace\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # collections + hooks + the archive function\n```\n\n## The five things, and where each one lives\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| Customer companies + memberships | `features.orgs` | `POST /v1/orgs`, members, invitations, a permission check \u2014 no membership table of your own |\n| A `finance` role | **not config** \u2014 `POST /v1/orgs/roles` | roles are rows, so you add one without a deploy |\n| Roles on the session | `features.auth.orgClaims.enabled` | the member\'s active-org role rides the session token; a read can be gated on it with no round-trip |\n| SSO | `features.auth.providers.oidc` (commented) | one block swaps email+password for the customer\'s identity provider |\n| Lockout / breach / redirect fence | `features.auth.security` | the account-security controls, all opt-in, all off until you ask |\n| A 3-device cap | `features.auth.session.maxConcurrent` | a fourth sign-in takes over the oldest session and tells you which |\n| Hiding `budget_usd` | `readRoles: [\'finance\']` on the field | the field is **absent** for everyone else \u2014 and unfilterable, so it cannot be read one bit at a time |\n| Refusing an orphaning delete | `onDelete: \'restrict\'` on the relation | deleting a project that still holds documents is a clean 409, not a cascade you did not ask for |\n| The Archive button | `actions: [{ key, label, fn }]` | one human-initiated step, one function, no workflow engine |\n\n## SSO \u2014 the config swap\n\nThe blueprint ships with email + password so the walkthrough runs with no identity provider.\nTo move a workspace onto its company\'s IdP, uncomment the `providers.oidc` block in\n`vxil.config.ts`, fill in three values, store one secret, and push:\n\n```ts\nproviders: {\n oidc: {\n issuer: \'https://login.example-idp.com\', // https, no query or fragment\n clientId: \'vxil-team-workspace\', // not a secret \u2014 it rides every authorize URL\n clientSecretRef: \'secret:oidc_client_secret\', // a REFERENCE; the value never enters this file\n scopes: [\'email\', \'profile\'], // `openid` is always added\n claims: { email: \'email\', name: \'name\', roles: \'groups\' },\n allowedDomains: [\'example.com\'], // fail-closed: an unlisted domain is refused\n autoLink: true, // link to an existing verified email\n },\n},\n```\n\n```bash\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\nvxil push\n```\n\nThen send people to `GET /v1/auth/oauth/oidc/start?redirect_uri=https://app.example.com/callback`.\nThe authorize endpoint, token endpoint and signing keys are **discovered from the issuer** \u2014 there\nis nothing else to configure and no code change at all. The presence of the block is the opt-in;\nthere is no separate toggle.\n\nTwo claims feed the session\'s role list: the member\'s **active-org role** (from `orgClaims`) and\nwhatever claim you name in `claims.roles` (from the IdP). Either one alone is enough to satisfy\n`readRoles: [\'finance\']`, which is why the same config works before and after SSO.\n\nAny broker that speaks OIDC \u2014 Okta, Entra, Auth0, WorkOS \u2014 puts a SAML customer behind this same\nblock. There is deliberately no separate SAML surface to learn.\n\n## Inviting people: two different invitations\n\nThey are easy to confuse, so name them apart:\n\n- **Your customers\' teammates** \u2192 `POST /v1/orgs/{org_id}/invitations` with `{ email, role }`,\n where `role` is `admin`, `member` or `viewer`. The single-use token comes back **once**, in that\n response \u2014 it is deliberately never emailed, so your app builds its own accept link and controls\n the wording. Accept with `POST /v1/orgs/invitations/accept`; list pending ones with\n `GET /v1/orgs/{org_id}/invitations`; revoke with `DELETE /v1/orgs/invitations/{invite_id}`.\n To land someone on a custom role such as `finance`, invite them as `member` and then\n `POST /v1/orgs/{org_id}/members` with the role. There is no resend \u2014 issue a new invitation and\n revoke the old one.\n- **Your own colleagues, on the vxil project itself** \u2192 the dashboard\'s **Members \u2192 Invite by\n email**. Type an address and it becomes a *pending* row with Resend and Revoke beside it; when\n they accept, they get a dashboard seat on this backend. That one is pure operator flow \u2014 no code,\n nothing in this config.\n\n## The 10-minute walkthrough\n\nEvery response below is the real shape. `$KEY` is a server key with `cms:read cms:write orgs:read\norgs:write auth:signin auth:write features:read`; `$API` is `https://api.vxil.com`.\n\n**1. Define the `finance` role.** Roles are rows, so this needs no deploy.\n\n```bash\nvxil api POST /v1/orgs/roles --data \'{"role_key":"finance","name":"Finance","permissions":["budgets.approve","reports.export"],"rank":2}\'\n# 201 { "data": { "role_key": "finance", "name": "Finance", "permissions": [...], "rank": 2, ... } }\n```\n\nA custom role is a named **permission set**, and the permission strings are yours \u2014 vxil never\ninterprets them, it only answers whether this member holds one. The four built-in roles\n(`owner > admin > member > viewer`) keep working alongside it.\n\n**2. Create a workspace and two members.**\n\n```bash\nvxil api POST /v1/orgs --data \'{"slug":"northwind","name":"Northwind","owner_user_id":"u_owner"}\'\n# 201 { "data": { "org_id": "org_\u2026", "slug": "northwind", "name": "Northwind", "created_at": "\u2026" } }\n```\n\nSign two people up, then seat them \u2014 one plain `member`, one on `finance`:\n\n```bash\nvxil api POST /v1/auth/sign-up --data \'{"email":"alice@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/auth/sign-up --data \'{"email":"dana@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<alice>","role":"member"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<dana>","role":"finance"}\'\n# 201 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "role": "finance" } }\n```\n\nCheck what the session will carry:\n\n```bash\nvxil api GET "/v1/orgs/session-claims?user_id=<dana>"\n# 200 { "data": { "user_id": "\u2026", "org_id": "org_\u2026", "role": "finance", "perms": [...] } }\n```\n\n**3. Sign in \u2014 and watch the device cap.** Sign the same person in four times:\n\n```bash\ncurl -s -X POST "$API/v1/auth/sign-in" -H "authorization: Bearer $KEY" \\\n -H \'content-type: application/json\' \\\n -d \'{"email":"dana@example.com","password":"<a strong one>"}\'\n# 200 { "data": { "user_id": "\u2026",\n# "session": { "token": "\u2026", "refresh_token": "\u2026", "expires_at": "\u2026" },\n# "took_over": [ "sess_\u2026" ] } }\n```\n\nThe fourth sign-in reports the session it revoked in `took_over`. That array only appears because\n`session.maxConcurrent` is set \u2014 leave it out and responses are byte-identical to a backend that\nnever heard of the cap.\n\nRepeated wrong passwords stop being cheap after five: `429 account_locked` with a `Retry-After`\nheader, for fifteen minutes. Because the counter is keyed on a hash of the identifier, an unknown\naddress locks out exactly like a real one \u2014 no probing for which emails exist.\n\n**4. Write a document with a budget.** As the server key (no end-user session):\n\n```bash\nvxil api POST /v1/cms/items/projects --data \'{"data":{"name":"Northwind Rollout","code":"NW-2026","stage":"active"}}\'\nvxil api POST /v1/cms/items/documents --data \'{"data":{"title":"Statement of work","author":"<dana>","project":"<project item_id>","state":"draft","budget_usd":240000}}\'\n# 201 { "data": { "item_id": "itm_\u2026", "collection": "documents", "status": "draft",\n# "data": { "title": "\u2026", "budget_usd": 240000, \u2026 }, "version": 1, \u2026 } }\n```\n\nThe server key sees `budget_usd`. That is the point: the gate is for **end users**, not for you.\n\n**5. The gate, live.** Read the same document as a signed-in member, by sending the session\'s\n`token` in the `X-Vxil-End-User` header:\n\n```bash\n# dana \u2014 role `finance`\ncurl -s "$API/v1/cms/items/documents/<id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 \u2026 "data": { "title": "Statement of work", "budget_usd": 240000, "state": "draft", \u2026 }\n\n# alice \u2014 role `member`\ncurl -s "$API/v1/cms/items/documents/<her own document\'s id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 200 \u2026 "data": { "title": "\u2026", "state": "draft", \u2026 } \u2190 budget_usd is ABSENT\n```\n\nAbsent, not `null` \u2014 a `null` would itself be an answer. And it cannot be reached sideways either:\n\n```bash\ncurl -s "$API/v1/cms/items/documents?filter=%7B%22budget_usd%22%3A%7B%22%24gt%22%3A0%7D%7D" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 422 { "error": { "code": "invalid_query", "message": "unknown field \'budget_usd\' \u2026" } }\n```\n\nTo a member without the role the field does not exist \u2014 not in the document, not in a filter, not\nin a sort, not through an expanded relation. Promote alice to `finance`, have her sign in again,\nand the field is simply there: the role travels on the session, so a new session is all it takes.\n\n**6. A delete that refuses.** The project still has a document pointing at it:\n\n```bash\nvxil api DELETE /v1/cms/items/projects/<project item_id>\n# 409 { "error": { "code": "referenced",\n# "message": "this item is still referenced by 1 live item(s) through an on_delete: \'restrict\' relation \u2026; nothing was deleted.",\n# "referencing": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" } ],\n# "has_more": false } }\n```\n\nAsk who is holding it, the same way the 409 did:\n\n```bash\nvxil api GET /v1/cms/items/projects/<project item_id>/backlinks\n# 200 { "data": { "collection": "projects", "item_id": "itm_\u2026",\n# "backlinks": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" } ],\n# "count": 1, "has_more": false, "limit": 25 } }\n```\n\n**7. The button.** One action, one function, one step:\n\n```bash\ncurl -s -X POST "$API/v1/cms/items/documents/<id>/actions/archive" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 { "data": { "collection": "documents", "item_id": "itm_\u2026", "action": "archive",\n# "fn": "archive-document",\n# "result": { "archived": "itm_\u2026", "from": "draft", "state": "archived" } } }\n```\n\nThe function receives `{ collection, item_id, action, actor, item }` and runs with **the pressing\nmember\'s** verified identity, so its write is owner-scoped exactly as if they had made it. Press it\nagain and it answers `already: true` \u2014 a button a human can double-click needs to be idempotent.\n\nTry an illegal jump instead (`archived \u2192 draft`) and the collection\'s lifecycle hook rejects it\ninside the same write, so the button and the API can never disagree:\n\n```bash\nvxil api PATCH /v1/cms/items/documents/<id> --data \'{"data":{"state":"draft"}}\'\n# 422 \u2026 "illegal document state transition"\n```\n\n**8. Real authorization, when advisory is not enough.** The session role is a *snapshot*, refreshed\nwith the session. For anything that must reflect a revocation immediately, ask:\n\n```bash\nvxil api GET "/v1/orgs/org_\u2026/check?user_id=<dana>&permission=budgets.approve"\n# 200 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "permission": "budgets.approve",\n# "role": "finance", "allowed": true, "source": "role" } }\n```\n\n## What to learn from this\n\n- **Roles are data; the gate is config.** `finance` is a row you can create at 4pm on a Friday.\n `readRoles: [\'finance\']` is one field attribute. Neither is a code path you maintain.\n- **Field-level security has to be fail-safe in every direction, or it is theatre.** A gated field\n is removed from the document, from filters, from sorts, from expanded relations, and it is never\n served on a public read lane. The only caller that still sees it is your own backend.\n- **Owner-scoping and role-gating answer different questions.** `ownerField` decides *which rows*\n a member can see. `readRoles` decides *which fields* inside a row they get. You usually want both.\n- **`restrict` beats a cascade you did not think about.** Refusing the delete and naming the\n holders turns a data-loss bug into a 409 your UI can explain.\n- **An action is one step, on purpose.** The moment a button needs conditions or a second step, it\n is a function of yours, not a config entry \u2014 and that boundary is what keeps this from becoming a\n workflow engine.\n\n**Pairs with:** `templates/crm/` (the same relational spine without the org layer) and\n`templates/helpdesk/` (owner-scoped records with a state machine).\n',
17941
+ "readme": '# Team Workspace (saas)\n\nThe **B2B** blueprint: many customer companies inside one backend, each with its own members\nand roles, signing in through the company\'s own identity provider \u2014 and a document set where\none field is visible only to finance.\n\nFive things most "add multi-tenancy to my SaaS" projects end up building by hand, declared here\ninstead: **organizations**, **roles that ride the session**, **SSO as a config swap**,\n**field-level read security**, and **one button per record**.\n\n```bash\nvxil init --template team-workspace\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # collections + hooks + the archive function\n```\n\n> **Plan note.** The archive function deploys on the Free plan when the project\'s workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n## The five things, and where each one lives\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| Customer companies + memberships | `features.orgs` | `POST /v1/orgs`, members, invitations, a permission check \u2014 no membership table of your own |\n| A `finance` role | **not config** \u2014 `POST /v1/orgs/roles` | roles are rows, so you add one without a deploy |\n| Roles on the session | `features.auth.orgClaims.enabled` | the member\'s active-org role rides the session token; a read can be gated on it with no round-trip |\n| SSO | `features.auth.providers.oidc` (commented) | one block swaps email+password for the customer\'s identity provider |\n| Lockout / breach / redirect fence | `features.auth.security` | the account-security controls, all opt-in, all off until you ask |\n| A 3-device cap | `features.auth.session.maxConcurrent` | a fourth sign-in takes over the oldest session and tells you which |\n| Hiding `budget_usd` | `readRoles: [\'finance\']` on the field | the field is **absent** for everyone else \u2014 and unfilterable, so it cannot be read one bit at a time |\n| Refusing an orphaning delete | `onDelete: \'restrict\'` on the relation | deleting a project that still holds documents is a clean 409, not a cascade you did not ask for |\n| The Archive button | `actions: [{ key, label, fn }]` | one human-initiated step, one function, no workflow engine |\n\n## SSO \u2014 the config swap\n\nThe blueprint ships with email + password so the walkthrough runs with no identity provider.\nTo move a workspace onto its company\'s IdP, uncomment the `providers.oidc` block in\n`vxil.config.ts`, fill in three values, store one secret, and push:\n\n```ts\nproviders: {\n oidc: {\n issuer: \'https://login.example-idp.com\', // https, no query or fragment\n clientId: \'vxil-team-workspace\', // not a secret \u2014 it rides every authorize URL\n clientSecretRef: \'secret:oidc_client_secret\', // a REFERENCE; the value never enters this file\n scopes: [\'email\', \'profile\'], // `openid` is always added\n claims: { email: \'email\', name: \'name\', roles: \'groups\' },\n allowedDomains: [\'example.com\'], // fail-closed: an unlisted domain is refused\n autoLink: true, // link to an existing verified email\n },\n},\n```\n\n```bash\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\nvxil push\n```\n\nThen send people to `GET /v1/auth/oauth/oidc/start?redirect_uri=https://app.example.com/callback`.\nThe authorize endpoint, token endpoint and signing keys are **discovered from the issuer** \u2014 there\nis nothing else to configure and no code change at all. The presence of the block is the opt-in;\nthere is no separate toggle.\n\nTwo claims feed the session\'s role list: the member\'s **active-org role** (from `orgClaims`) and\nwhatever claim you name in `claims.roles` (from the IdP). Either one alone is enough to satisfy\n`readRoles: [\'finance\']`, which is why the same config works before and after SSO.\n\nAny broker that speaks OIDC \u2014 Okta, Entra, Auth0, WorkOS \u2014 puts a SAML customer behind this same\nblock. There is deliberately no separate SAML surface to learn.\n\n## Inviting people: two different invitations\n\nThey are easy to confuse, so name them apart:\n\n- **Your customers\' teammates** \u2192 `POST /v1/orgs/{org_id}/invitations` with `{ email, role }`,\n where `role` is `admin`, `member` or `viewer`. The single-use token comes back **once**, in that\n response \u2014 it is deliberately never emailed, so your app builds its own accept link and controls\n the wording. Accept with `POST /v1/orgs/invitations/accept`; list pending ones with\n `GET /v1/orgs/{org_id}/invitations`; revoke with `DELETE /v1/orgs/invitations/{invite_id}`.\n To land someone on a custom role such as `finance`, invite them as `member` and then\n `POST /v1/orgs/{org_id}/members` with the role. There is no resend \u2014 issue a new invitation and\n revoke the old one.\n- **Your own colleagues, on the vxil project itself** \u2192 the dashboard\'s **Members \u2192 Invite by\n email**. Type an address and it becomes a *pending* row with Resend and Revoke beside it; when\n they accept, they get a dashboard seat on this backend. That one is pure operator flow \u2014 no code,\n nothing in this config.\n\n## The 10-minute walkthrough\n\nEvery response below is the real shape. `$KEY` is a server key with `cms:read cms:write orgs:read\norgs:write auth:signin auth:write features:read`; `$API` is `https://api.vxil.com`.\n\n**1. Define the `finance` role.** Roles are rows, so this needs no deploy.\n\n```bash\nvxil api POST /v1/orgs/roles --data \'{"role_key":"finance","name":"Finance","permissions":["budgets.approve","reports.export"],"rank":2}\'\n# 201 { "data": { "role_key": "finance", "name": "Finance", "permissions": [...], "rank": 2, ... } }\n```\n\nA custom role is a named **permission set**, and the permission strings are yours \u2014 vxil never\ninterprets them, it only answers whether this member holds one. The four built-in roles\n(`owner > admin > member > viewer`) keep working alongside it.\n\n**2. Create a workspace and two members.**\n\n```bash\nvxil api POST /v1/orgs --data \'{"slug":"northwind","name":"Northwind","owner_user_id":"u_owner"}\'\n# 201 { "data": { "org_id": "org_\u2026", "slug": "northwind", "name": "Northwind", "created_at": "\u2026" } }\n```\n\nSign two people up, then seat them \u2014 one plain `member`, one on `finance`:\n\n```bash\nvxil api POST /v1/auth/sign-up --data \'{"email":"alice@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/auth/sign-up --data \'{"email":"dana@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<alice>","role":"member"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<dana>","role":"finance"}\'\n# 201 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "role": "finance" } }\n```\n\nCheck what the session will carry:\n\n```bash\nvxil api GET "/v1/orgs/session-claims?user_id=<dana>"\n# 200 { "data": { "user_id": "\u2026", "org_id": "org_\u2026", "role": "finance", "perms": [...] } }\n```\n\n**3. Sign in \u2014 and watch the device cap.** Sign the same person in four times:\n\n```bash\ncurl -s -X POST "$API/v1/auth/sign-in" -H "authorization: Bearer $KEY" \\\n -H \'content-type: application/json\' \\\n -d \'{"email":"dana@example.com","password":"<a strong one>"}\'\n# 200 { "data": { "user_id": "\u2026",\n# "session": { "token": "\u2026", "refresh_token": "\u2026", "expires_at": "\u2026" },\n# "took_over": [ "sess_\u2026" ] } }\n```\n\nThe fourth sign-in reports the session it revoked in `took_over`. That array only appears because\n`session.maxConcurrent` is set \u2014 leave it out and responses are byte-identical to a backend that\nnever heard of the cap.\n\nRepeated wrong passwords stop being cheap after five: `429 account_locked` with a `Retry-After`\nheader, for fifteen minutes. Because the counter is keyed on a hash of the identifier, an unknown\naddress locks out exactly like a real one \u2014 no probing for which emails exist.\n\n**4. Write a document with a budget.** As the server key (no end-user session):\n\n```bash\nvxil api POST /v1/cms/items/projects --data \'{"data":{"name":"Northwind Rollout","code":"NW-2026","stage":"active"}}\'\nvxil api POST /v1/cms/items/documents --data \'{"data":{"title":"Statement of work","author":"<dana>","project":"<project item_id>","state":"draft","budget_usd":240000}}\'\n# 201 { "data": { "item_id": "itm_\u2026", "collection": "documents", "status": "draft",\n# "data": { "title": "\u2026", "budget_usd": 240000, \u2026 }, "version": 1, \u2026 } }\n```\n\nThe server key sees `budget_usd`. That is the point: the gate is for **end users**, not for you.\n\n**5. The gate, live.** Read the same document as a signed-in member, by sending the session\'s\n`token` in the `X-Vxil-End-User` header:\n\n```bash\n# dana \u2014 role `finance`\ncurl -s "$API/v1/cms/items/documents/<id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 \u2026 "data": { "title": "Statement of work", "budget_usd": 240000, "state": "draft", \u2026 }\n\n# alice \u2014 role `member`\ncurl -s "$API/v1/cms/items/documents/<her own document\'s id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 200 \u2026 "data": { "title": "\u2026", "state": "draft", \u2026 } \u2190 budget_usd is ABSENT\n```\n\nAbsent, not `null` \u2014 a `null` would itself be an answer. And it cannot be reached sideways either:\n\n```bash\ncurl -s "$API/v1/cms/items/documents?filter=%7B%22budget_usd%22%3A%7B%22%24gt%22%3A0%7D%7D" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 422 { "error": { "code": "invalid_query", "message": "unknown field \'budget_usd\' \u2026" } }\n```\n\nTo a member without the role the field does not exist \u2014 not in the document, not in a filter, not\nin a sort, not through an expanded relation. Promote alice to `finance`, have her sign in again,\nand the field is simply there: the role travels on the session, so a new session is all it takes.\n\n**6. A delete that refuses.** The project still has a document pointing at it:\n\n```bash\nvxil api DELETE /v1/cms/items/projects/<project item_id>\n# 409 { "error": { "code": "referenced",\n# "message": "this item is still referenced by 1 live item(s) through an on_delete: \'restrict\' relation \u2026; nothing was deleted.",\n# "referencing": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" } ],\n# "has_more": false } }\n```\n\nAsk who is holding it, the same way the 409 did:\n\n```bash\nvxil api GET /v1/cms/items/projects/<project item_id>/backlinks\n# 200 { "data": { "collection": "projects", "item_id": "itm_\u2026",\n# "backlinks": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" } ],\n# "count": 1, "has_more": false, "limit": 25 } }\n```\n\n**7. The button.** One action, one function, one step:\n\n```bash\ncurl -s -X POST "$API/v1/cms/items/documents/<id>/actions/archive" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 { "data": { "collection": "documents", "item_id": "itm_\u2026", "action": "archive",\n# "fn": "archive-document",\n# "result": { "archived": "itm_\u2026", "from": "draft", "state": "archived" } } }\n```\n\nThe function receives `{ collection, item_id, action, actor, item }` and runs with **the pressing\nmember\'s** verified identity, so its write is owner-scoped exactly as if they had made it. Press it\nagain and it answers `already: true` \u2014 a button a human can double-click needs to be idempotent.\n\nTry an illegal jump instead (`archived \u2192 draft`) and the collection\'s lifecycle hook rejects it\ninside the same write, so the button and the API can never disagree:\n\n```bash\nvxil api PATCH /v1/cms/items/documents/<id> --data \'{"data":{"state":"draft"}}\'\n# 422 \u2026 "illegal document state transition"\n```\n\n**8. Real authorization, when advisory is not enough.** The session role is a *snapshot*, refreshed\nwith the session. For anything that must reflect a revocation immediately, ask:\n\n```bash\nvxil api GET "/v1/orgs/org_\u2026/check?user_id=<dana>&permission=budgets.approve"\n# 200 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "permission": "budgets.approve",\n# "role": "finance", "allowed": true, "source": "role" } }\n```\n\n## What to learn from this\n\n- **Roles are data; the gate is config.** `finance` is a row you can create at 4pm on a Friday.\n `readRoles: [\'finance\']` is one field attribute. Neither is a code path you maintain.\n- **Field-level security has to be fail-safe in every direction, or it is theatre.** A gated field\n is removed from the document, from filters, from sorts, from expanded relations, and it is never\n served on a public read lane. The only caller that still sees it is your own backend.\n- **Owner-scoping and role-gating answer different questions.** `ownerField` decides *which rows*\n a member can see. `readRoles` decides *which fields* inside a row they get. You usually want both.\n- **`restrict` beats a cascade you did not think about.** Refusing the delete and naming the\n holders turns a data-loss bug into a 409 your UI can explain.\n- **An action is one step, on purpose.** The moment a button needs conditions or a second step, it\n is a function of yours, not a config entry \u2014 and that boundary is what keeps this from becoming a\n workflow engine.\n\n**Pairs with:** `templates/crm/` (the same relational spine without the org layer) and\n`templates/helpdesk/` (owner-scoped records with a state machine).\n',
18290
17942
  "functions": {
18291
- "archive-document.ts": "// archive-document.ts \u2014 the ARCHIVE BUTTON (a vxil function).\n//\n// Trigger: the per-record action `archive` declared on the `documents`\n// collection. Pressing the button POSTs\n// /v1/cms/items/documents/<id>/actions/archive\n// and the platform invokes THIS function once with the action envelope as its\n// payload:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// The pressing member's verified principal is carried into the scoped token, so\n// the PATCH below is owner-scoped exactly as if the member had written it \u2014 a\n// member can archive their own document and nobody else's, with no check here.\n//\n// It writes ONE transition (\u2192 'archived'). The collection's lifecycle hook is\n// still the authority: an illegal transition is rejected in the same write, and\n// this function reports that rejection instead of pretending it succeeded.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ninterface ActionPayload {\n collection?: string;\n item_id?: string;\n action?: string;\n actor?: { principal?: string; end_user_id?: string };\n item?: { status?: string; version?: number; data?: Record<string, unknown> };\n}\ntype Env = HttpFunctionEnvelope<ActionPayload>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (!cms || !itemId || env.payload?.collection !== 'documents') {\n return Response.json({ skipped: true, reason: 'not a documents action' });\n }\n\n // Already archived \u2192 nothing to do. The action is human-initiated and can be\n // pressed twice; make the second press a no-op rather than an error.\n const was = String(env.payload?.item?.data?.state ?? '');\n if (was === 'archived') {\n return Response.json({ archived: itemId, already: true, state: 'archived' });\n }\n\n const res = await fetch(`${base}/v1/cms/items/documents/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { state: 'archived', updated_at: new Date().toISOString() },\n }),\n });\n\n if (!res.ok) {\n // The lifecycle hook refuses an illegal transition in-transaction (422).\n // Surface the real reason \u2014 the action route relays this class straight\n // back to the caller as `action_failed`.\n const detail = await res.text();\n return Response.json(\n { error: 'archive_refused', from: was, status: res.status, detail: detail.slice(0, 300) },\n { status: res.status === 422 ? 422 : 502 },\n );\n }\n\n return Response.json({ archived: itemId, from: was || 'draft', state: 'archived' });\n },\n};\n"
17943
+ "archive-document.ts": "// archive-document.ts \u2014 the ARCHIVE BUTTON (a vxil function).\n//\n// Trigger: the per-record action `archive` declared on the `documents`\n// collection. Pressing the button POSTs\n// /v1/cms/items/documents/<id>/actions/archive\n// and the platform invokes THIS function once with the action envelope as its\n// payload:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// The pressing member's verified principal is carried into the scoped token, so\n// the PATCH below is owner-scoped exactly as if the member had written it \u2014 a\n// member can archive their own document and nobody else's, with no check here.\n//\n// It writes ONE transition (\u2192 'archived'). The collection's lifecycle hook is\n// still the authority: an illegal transition is rejected in the same write, and\n// this function reports that rejection instead of pretending it succeeded.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ninterface ActionPayload {\n collection?: string;\n item_id?: string;\n action?: string;\n actor?: { principal?: string; end_user_id?: string };\n item?: { status?: string; version?: number; data?: Record<string, unknown> };\n}\ntype Env = HttpFunctionEnvelope<ActionPayload>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); this guard stays as belt-and-braces.\n if (!cms || !itemId || env.payload?.collection !== 'documents') {\n return Response.json({ skipped: true, reason: 'not a documents action' });\n }\n\n // Already archived \u2192 nothing to do. The action is human-initiated and can be\n // pressed twice; make the second press a no-op rather than an error.\n const was = String(env.payload?.item?.data?.state ?? '');\n if (was === 'archived') {\n return Response.json({ archived: itemId, already: true, state: 'archived' });\n }\n\n const res = await fetch(`${base}/v1/cms/items/documents/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { state: 'archived', updated_at: new Date().toISOString() },\n }),\n });\n\n if (!res.ok) {\n // The lifecycle hook refuses an illegal transition in-transaction (422).\n // Surface the real reason \u2014 the action route relays this class straight\n // back to the caller as `action_failed`.\n const detail = await res.text();\n return Response.json(\n { error: 'archive_refused', from: was, status: res.status, detail: detail.slice(0, 300) },\n { status: res.status === 422 ? 422 : 502 },\n );\n }\n\n return Response.json({ archived: itemId, from: was || 'draft', state: 'archived' });\n },\n};\n"
18292
17944
  }
18293
17945
  },
18294
17946
  {
@@ -18314,11 +17966,11 @@ export default defineConfig({
18314
17966
  "hasFunctions": true,
18315
17967
  "byoKeys": [],
18316
17968
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Funnel SaaS\" \u2014 the STUDIO STARTER. A web-first funnel product, declared\n// end-to-end in ONE typed file:\n//\n// guest session \u2192 guided steps \u2192 e-mail claim \u2192 account \u2192 pass \u2192 AI brief\n//\n// What it composes (every block is a shipped feature; nothing here is a\n// platform primitive you would have to build):\n// \u2022 auth \u2192 anonymous sign-in for the visitor, an OTP e-mail claim that\n// PROMOTES the guest in place (same id, same rows), a session\n// pair the server refreshes behind a long-lived cookie\n// \u2022 cms \u2192 two owner-scoped collections behind `strictEndUserScope`:\n// a signed-in visitor sees ONLY their own rows, and a\n// collection without an owner field is server-only\n// \u2022 rate-limits \u2192 two policies DECLARED here, converged by `vxil push`\n// \u2022 webhooks \u2192 failure alerts to the owners + ONE declared outbound\n// subscription for your ops endpoint\n// \u2022 ai \u2192 a stored prompt template DECLARED here; the brief runs on\n// the job lane with a `correlation_id` = the session row id\n// \u2022 payments \u2192 a payments INTEGRATION: your own provider account (mock\n// here), vxil never in the flow of funds\n// \u2022 functions \u2192 three event-driven functions (below)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n auth: {\n // Visitors never see a password form: a guest session first, an e-mail\n // code to claim it later (magic link stays on as the alternative).\n methods: { emailPassword: false, magicLink: true },\n anonymous: { enabled: true },\n // The claim rides the OTP machinery (POST /v1/auth/anonymous/link/request\n // + /verify). `testRecipients` is where your CI address goes \u2014 a listed\n // address gets no mail and the 202 carries `test_code`.\n otp: { enabled: true, codeTtlMinutes: 10, maxAttempts: 5, resendCooldownSec: 60 },\n // The pair your server middleware rotates (README: \"keep the token fresh\").\n session: { ttlMinutes: 60, refreshTtlDays: 30 },\n },\n\n cms: {\n // A funnel row is live the moment it is written \u2014 no editorial draft step.\n draftPublish: false,\n // THE FAIL-SAFE. In end-user mode a collection with no `ownerField` is\n // server-only (403 server_only on read AND write) instead of\n // tenant-wide-shared. Every collection below declares an owner, so a\n // visitor can only ever reach their own rows; a collection you add later\n // without one is closed to them until you say otherwise.\n strictEndUserScope: true,\n hooks: {\n // Steps only move forward \u2014 a client cannot rewind a completed intake.\n // (`coalesce` so a row created without a step can still be stepped.)\n steps_forward_only: {\n collection: 'funnel_sessions',\n event: 'beforeUpdate',\n kind: 'validate',\n expr: 'coalesce(item.step, 0) >= coalesce(before.step, 0)',\n message: 'steps only move forward',\n },\n // The session state machine: in_progress \u2192 completed, and completed is\n // terminal (the brief function flips it; nothing flips it back).\n session_transition: {\n collection: 'funnel_sessions',\n event: 'beforeUpdate',\n kind: 'validate',\n expr: \"item.status == before.status || (before.status == 'in_progress' && item.status == 'completed')\",\n message: 'illegal session status transition',\n },\n // A pass is bound to the charge that paid for it, forever.\n pass_charge_immutable: {\n collection: 'passes',\n event: 'beforeUpdate',\n kind: 'validate',\n expr: 'item.charge_id == before.charge_id',\n message: 'a pass keeps the charge that paid for it',\n },\n },\n },\n\n // Auth mail (the claim code) and the \"your brief is ready\" message ride\n // this. `mock` needs no account; point it at your own provider to go live.\n notifications: { provider: 'mock', fromEmail: 'hello@studio.example' },\n\n // DECLARED API STATE #1 \u2014 rate-limit policies. These used to be rows you\n // created by hand with POST /v1/rate-limits/policies and re-created in every\n // environment; now `vxil push` converges the live set onto this list by\n // name (creates what is missing, patches what differs, REPORTS what you did\n // not declare). Your server checks them with POST /v1/rate-limits/check.\n 'rate-limits': {\n enabled: true,\n policies: [\n // one visitor, thirty step writes a minute \u2014 enough for a human, not a loop\n { name: 'funnel-step', key_template: '{tenant_id}:{user_id}', limit: 30, window_seconds: 60 },\n // three claim codes per address per ten minutes; pass a HASH of the\n // address as `email_hash`, never the address itself (it becomes a key)\n { name: 'claim-request', key_template: '{tenant_id}:{email_hash}', limit: 3, window_seconds: 600, behavior: 'block' },\n ],\n },\n\n webhooks: {\n enabled: true,\n // FAILURE ALERTS. Every error-level failure event in this backend (a dead\n // delivery, a failed generation, a missed schedule) is digested into a\n // mail to the project's owner accounts every 15 minutes. Recipients are\n // deliberately not configurable; `digestMinutes: 0` makes it immediate.\n alerts: { enabled: true, minLevel: 'error', digestMinutes: 15 },\n // DECLARED API STATE #2 \u2014 an outbound subscription. Converged by\n // target_url on push; a live row you did not declare is left and\n // reported. The three function triggers below are NOT declared here \u2014\n // their subscriptions derive from the functions manifest.\n subscriptions: [\n { target_url: 'https://ops.studio.example/vxil/events', event_prefixes: ['payments.', 'auth.user.'] },\n ],\n },\n\n ai: {\n enabled: true,\n defaultProvider: 'mock', // BYO key later: providers.openaiKeyRef / anthropicKeyRef / geminiKeyRef\n defaults: { maxTokens: 800, temperature: 0.4 },\n // DECLARED API STATE #3 \u2014 the stored prompt template. Converged by\n // CONTENT: a push stores a new version only when the text changed, so\n // pushes are idempotent and versions stay monotonic. vxil stores and\n // renders it; the wording is yours.\n templates: [\n {\n template: 'studio-brief',\n system:\n 'You are a senior brand strategist. Write in plain, confident English. ' +\n 'Never invent facts about the client; when something is unknown, say what you would need.',\n user:\n 'Write a one-page creative brief for a studio client.\\n\\n' +\n 'Goal: {{goal}}\\nAudience: {{audience}}\\nTone: {{tone}}\\nConstraints: {{constraints}}\\n\\n' +\n 'Sections: Objective, Audience insight, Key message, Deliverables, Next steps.',\n },\n ],\n },\n\n // The job lane the brief runs on. `retry` is the ladder for JOB RUNS \u2014 the\n // generation itself, and deliveries to your own endpoints. It is NOT a\n // retry of what a platform-delivered function ANSWERS: the dispatcher ACKs\n // every trigger delivery with a 200 whatever the function returned (guide\n // 08, \"What is retried, and what is not\"), so the three functions below\n // make their failures recoverable through the rows they own \u2014 a 5xx from\n // one of them is observed (`functions.run.failed` \u2192 `webhooks.alerts`),\n // never re-delivered.\n jobs: { enabled: true, retry: { defaultMaxAttempts: 3 } },\n\n // A payments INTEGRATION: the tenant's own Stripe/Paddle/PayPal/RevenueCat\n // account; vxil is never in the flow of funds. `mock` needs no account and\n // runs everything UP TO the paywall \u2014 push, deploy, the intake, the claim\n // and every refusal (402 pass_required, 409 intake_incomplete). It cannot\n // put a PAID charge on the ledger: its checkout URL is a placeholder, its\n // webhooks are signed with the platform's secret, and\n // `vx.payments.simulate` scripts subscriptions, not one-off charges \u2014 so on\n // `mock` grant-pass never wakes and start-brief answers 402 to everyone.\n // The paid path needs your provider's sandbox (README, \"Running the paid\n // path\"), e.g. a Stripe test-mode account:\n // payments: { enabled: true, provider: 'stripe', defaults: { currency: 'usd' },\n // stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' } },\n payments: { enabled: true, provider: 'mock', defaults: { currency: 'usd' } },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // One row per visitor intake. Created and stepped by the browser as the\n // (guest, then claimed) end-user; completed and settled by functions.\n funnel_sessions: {\n singular: 'funnel_session',\n // The owner. In end-user mode the platform STAMPS this with the verified\n // user id on create and scopes every read/patch/delete to it \u2014 a body\n // naming someone else is a 400, and a foreign row is an honest 404.\n ownerField: 'user',\n fields: {\n user: { type: 'string', required: true, indexSlot: 's1' },\n status: { type: 'string', indexSlot: 's2', validation: { enum: ['in_progress', 'completed'] } },\n generation_id: { type: 'string', indexSlot: 's3' }, // the job-lane generation that owns the result\n result_status: { type: 'string', indexSlot: 's4', validation: { enum: ['queued', 'completed', 'failed'] } },\n step: { type: 'int', indexSlot: 'n1', validation: { min: 1, max: 4 } }, // 1..4, forward only\n started_at: { type: 'datetime', indexSlot: 't1' },\n completed_at: { type: 'datetime', indexSlot: 't2' },\n answers: { type: 'json' }, // { goal, audience, tone, constraints } \u2014 the template's inputs\n result: { type: 'text' }, // the settled brief\n error_hint: { type: 'string' }, // the provider's own words when the brief failed\n },\n },\n\n // One row per PAID pass, written by the grant function on the provider's\n // charge event. `charge_id` is the dedupe anchor: an at-least-once\n // redelivery of the same charge is a clean 409 the function treats as\n // \"already granted\". It is the DISPLAY RECORD, never the entitlement: the\n // collection is owned, so the visitor's own thin-client key (cms:write)\n // can create a pass or stretch its expires_at \u2014 start-brief decides from\n // the owner-bound payments ledger instead.\n passes: {\n singular: 'pass',\n ownerField: 'user',\n fields: {\n user: { type: 'string', required: true, indexSlot: 's1' },\n charge_id: { type: 'string', required: true, unique: true, indexSlot: 's2' },\n product: { type: 'string', indexSlot: 's3' },\n amount_cents: { type: 'int', indexSlot: 'n1' },\n granted_at: { type: 'datetime', indexSlot: 't1' },\n expires_at: { type: 'datetime', indexSlot: 't2' }, // the brief function's range filter\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: three event-driven functions \u2500\u2500\u2500\u2500\u2500\u2500\n functions: {\n // THE BRIEF. Invoked by the browser AS THE SIGNED-IN VISITOR\n // (vx.asEndUser(token).fn['start-brief']({ session_id })): the end-user\n // principal rides into the function, so its cms reads are owner-scoped and\n // the generation is billed to that user. Requires a PAID pass, read from\n // the owner-bound ledger (`payments:read`) on every call \u2014 never from the\n // visitor-writable `passes` row, which it re-writes as the record when a\n // grant delivery was lost; CLAIMS the row with If-Match before spending\n // anything (a double click is one generation);\n // starts the brief on the ai JOB lane with correlation_id = the session row id.\n 'start-brief': {\n entry: './functions/start-brief.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'ai:write', 'payments:read'],\n egressAllow: [],\n signature: {\n input: { session_id: 'string' },\n // Two 202 answers, so the output is a whole TS type (a union): a new\n // brief's handle, or `already_queued` when one is running \u2014 whose\n // generation_id is null while that claim has not recorded its handle.\n output:\n '{ generation_id: string; run_id: string; correlation_id: string }' +\n \" | { generation_id: string | null; status: 'already_queued' }\",\n },\n },\n\n // THE GRANT. Woken by ONE platform event \u2014 `payments.charge.succeeded`, the\n // full event name as the `source` (the prefix `payments.charge.` would wake\n // it twice per Paddle charge, on `succeeded` and on `completed`) \u2014 a few\n // seconds after your provider's webhook folds. If your pass were a TIER for\n // a number of days rather than a count of briefs, you would not write this\n // function at all: `payments.ledger.productMap['studio-pass'] = { tier: 'pass',\n // durationDays: 30 }` and the platform writes the pass on the charge event\n // (stacking, refund-aware). Re-reads the charge from the ledger (the event name is a\n // routing claim, not an attestation), then writes the pass. A 5xx from it\n // is ACKed and observed, not re-delivered \u2014 start-brief decides from the\n // same ledger, so a lost grant never blocks a payer.\n 'grant-pass': {\n entry: './functions/grant-pass.ts',\n trigger: { kind: 'webhook', source: 'payments.charge.succeeded' },\n scopes: ['payments:read', 'cms:write'],\n egressAllow: [],\n },\n\n // THE SETTLE. Woken by the `job.generation.` prefix \u2014 completed AND failed \u2014\n // and finds its own row through the event's `correlation_id` (no run\u2192record\n // table of your own). On completed: the brief text from the replay buffer\n // + a \"ready\" message. On failed: `error_hint` \u2014 the provider's own words \u2014\n // onto the row, so the visitor sees why, not just that. A settle that\n // cannot complete marks the row `failed` (the retry is one click) \u2014 the\n // delivery is ACKed whatever it answers, so recovery lives in the row.\n 'settle-brief': {\n entry: './functions/settle-brief.ts',\n trigger: { kind: 'webhook', source: 'job.generation.' },\n scopes: ['cms:read', 'cms:write', 'ai:read', 'notifications:send'],\n egressAllow: [],\n },\n },\n});\n",
18317
- "readme": "# Funnel SaaS \u2014 the studio starter (saas)\n\nThe **web-first funnel** blueprint: a visitor lands, starts as a **guest**, walks a guided intake,\n**claims** the session with an e-mail code, buys a **pass** through your own payments provider,\nand gets an AI-written brief **settled back onto their record** \u2014 all of it declared in one typed\n`vxil.config.ts`, with three small functions for the parts that are code.\n\nIt is the shape most \"studio\" products have \u2014 a lead magnet, a paid deliverable, an account that\nonly exists once someone cares \u2014 and it is also the blueprint that shows **declared API state**:\nrate-limit policies, an outbound subscription and a stored prompt template live in the config and\nconverge on `vxil push`, so a second environment is a push, not a runbook.\n\n```bash\nvxil init --template funnel-saas\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # collections + hooks + the three functions + the declared API state\nvxil gen # typed SDK: vx.from('funnel_sessions'), vx.fn['start-brief']\n```\n\n**What it provisions:**\n- `funnel_sessions` \u2014 `user` (the **owner field**), `status` (`in_progress \u2192 completed`, hook-guarded),\n `step` (1\u20134, forward only), `answers` (json), then the brief's `generation_id`, `result_status`\n (`queued | completed | failed`), `result` and `error_hint`.\n- `passes` \u2014 `user` (owner), `charge_id` (**unique** \u2014 the dedupe anchor), `product`, `amount_cents`,\n `granted_at`, `expires_at`. The record the page shows \u2014 the payments ledger is the entitlement.\n- Features: `auth` (guest + OTP claim + a refreshable session pair) \xB7 `cms` (**`strictEndUserScope: true`**) \xB7\n `rate-limits` (two **declared policies**) \xB7 `webhooks` (**`alerts` on** + one **declared subscription**) \xB7\n `ai` (one **declared template**) \xB7 `jobs` \xB7 `payments` (a payments **integration** \u2014 your provider; `mock`\n here, which runs everything *up to* the paywall \u2014 [Running the paid path](#running-the-paid-path)) \xB7\n `notifications` (`mock`) \xB7 `functions`.\n- Functions: `start-brief` (http, invoked as the visitor), `grant-pass` (webhook on\n `payments.charge.succeeded`), `settle-brief` (webhook on the `job.generation.` prefix).\n\n> **A simpler pass, if your pass is a tier for a number of days.** Since 2026-09-25 a\n> `ledger.productMap` entry may be `{ tier: 'pass', durationDays: 30 }`: the platform then writes\n> the pass itself on the charge event (stacking behind a live one, ended by a full refund or\n> chargeback), and `GET /v1/payments/entitlements` answers \"is this visitor on a pass, until when\".\n> This blueprint keeps its own `passes` record and `grant-pass` function on purpose \u2014 its pass is a\n> *count of briefs*, decided from the owner-bound charge ledger, not a tier for a duration.\n\n## The funnel, step by step\n\nEvery call below is the browser talking to your backend \u2014 with **two keys**, the way\nvxil.com/docs/guide/09-security-and-multitenancy prescribes: a **sign-in key** (the ordinary class,\ncarrying only `auth:signin`) for the moment *before* there is a session, and a **thin-client key**\n(`key_class: 'public'` \u2014 `cms:read`, `cms:write`, `functions:invoke`) for everything after, which the\nedge refuses unless it rides with a valid end-user token. A public key *cannot* carry `payments:write`\n(a `422` at mint), so the checkout is the one call your server makes \u2014 the last section.\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nconst signin = Vxil.connect({ apiKey: SIGNIN_KEY }); // auth:signin only \u2014 works with no session\nconst thin = Vxil.connect({ apiKey: THIN_CLIENT_KEY }); // public class \u2014 needs a session on every call\n\n// 1. A guest. Real user id, real session, no e-mail yet (the token carries `anon: true`).\nconst guest = await signin.auth.anonymous.signIn();\nconst me = thin.asEndUser(guest.session.token);\n\n// 2. The intake. Owner-scoped: the platform stamps `user` with the guest's id on create,\n// and this session (and only this session) can read and step the row.\nconst { item_id: session_id } = await me.from('funnel_sessions').create({\n status: 'in_progress', step: 1, started_at: new Date().toISOString(),\n answers: { goal: 'Launch a coffee subscription' },\n});\nawait me.from('funnel_sessions').patch(session_id, { step: 2, answers: { goal: '\u2026', audience: '\u2026' } });\n// \u2026steps 3 and 4. A PATCH that moves `step` backwards is a 422 from the `steps_forward_only` hook.\n\n// 3. The claim. A 6-digit code proves the address; the guest is PROMOTED IN PLACE \u2014\n// same user id, so the intake rows are already theirs. (Sign-in class: the sign-in key.)\nawait signin.auth.anonymous.link.request({ token: guest.session.token, email: 'ada@example.com' });\nconst claimed = await signin.auth.anonymous.link.verify({ token: guest.session.token, email: 'ada@example.com', code: '123456' });\n// claimed.user_id === guest.user_id \u2014 unless the address already had an account: then\n// `merged: true`, a fresh session, and `rekeyed` says whether the guest's rows moved already.\n\n// 4. The pass \u2014 only after the claim. Your SERVER refuses a guest, creates the hosted checkout\n// (below) and hands the URL back; the provider's webhook folds the charge \u2192\n// `payments.charge.succeeded` \u2192 grant-pass writes the pass record.\n\n// 5. The brief. Invoked AS THE VISITOR: the function's reads are owner-scoped and the\n// generation is metered to them. Until the visitor's ledger holds a paid charge the call\n// throws a VxilError whose `.code` is 'pass_required' (402); an unfinished intake is\n// 'intake_incomplete' (409).\nconst handle = await me.fn['start-brief']({ session_id });\n// \u2192 { generation_id, run_id, correlation_id } for a new brief, or \u2014 a second click while one\n// is running \u2014 { generation_id, status: 'already_queued' }: one generation, metered once,\n// and `generation_id` is null while that attempt has not recorded its handle yet (the\n// declared signature is that union). Either way, watch the ROW, not the handle:\n// \u2026seconds to minutes later, settle-brief writes `result` (or `error_hint`) onto the row and\n// notifications delivers \"Your brief is ready\". A failed brief can be started again: the\n// same call on a row whose result_status is 'failed' queues a new generation.\n```\n\n## What to learn from this\n\n- **`strictEndUserScope: true` is the fail-safe version of owner scoping.** Both collections declare an\n `ownerField`, so a signed-in visitor reaches exactly their own rows; a collection you add later *without*\n one is `403 server_only` for end-users on read and write instead of tenant-wide-shared. Nothing about a\n server call changes \u2014 a server key still sees everything\n (vxil.com/docs/guide/09-security-and-multitenancy).\n- **A guest is a real end-user.** `vx.auth.anonymous.signIn()` mints a real id; owner-scoped rows written\n before the claim stay attached because the OTP claim (`POST /v1/auth/anonymous/link/request` + `/verify`)\n promotes the guest in place. Passing the guest's token as `anonymous_token` on a magic-link request does\n the same through mail (vxil.com/docs/guide/06-feature-catalog).\n- **Declared API state.** Three things that used to be rows you created by hand \u2014 and re-created in every\n environment \u2014 are config now, and `vxil push` converges the live rows onto them, reporting (never\n deleting) what you did not declare:\n\n | Block | Converged by | The live rows it replaces |\n |---|---|---|\n | `features['rate-limits'].policies[]` | `name` | `POST /v1/rate-limits/policies` |\n | `features.webhooks.subscriptions[]` | `target_url` | `POST /v1/webhooks/subscriptions` |\n | `features.ai.templates[]` | content hash | `POST /v1/ai/templates` (a new version only when the text changed) |\n\n The three function triggers are *not* declared here \u2014 a `webhook`-trigger binding on a function is its\n own declaration, and the platform reconciles that subscription from the functions manifest.\n- **An event-driven grant, done honestly.** `grant-pass` wakes on the full event name\n `payments.charge.succeeded` a few seconds after your provider's webhook folds. The event name is a\n *routing claim*, so the function re-reads the ledger (`GET /v1/payments/charges`) before writing; the\n `passes.charge_id` unique field (the ledger's charge id) turns an at-least-once redelivery into a `409`\n it reads as \"already granted\". And it is honest about **what is not retried**: the dispatcher ACKs a\n platform-delivered function with a `200` *whatever it answered*, so a `5xx` from the API is never\n re-delivered \u2014 it is *observed* (`functions.run.failed`, once per failure streak, in the\n `webhooks.alerts` digest), and recovery is data, not delivery: `start-brief` decides from the same\n owner-bound ledger on every request, so a lost grant never blocks a payer, and it writes the missing\n pass record for that charge (a `409` there just means it is recorded already)\n (vxil.com/docs/guide/08-running-your-code-functions).\n- **An owned row the visitor can write is never an entitlement.** `passes` declares an `ownerField`, so\n the thin-client key (`cms:write`) lets a visitor *create* a pass of their own or PATCH its\n `expires_at` to 2099 \u2014 the platform stamps them as the owner, which proves who wrote the row, not that\n anyone paid, and a write hook's `caller` is always `null`, so no hook can tell that write from\n `grant-pass`'s (vxil.com/docs/guide/07-validation-and-hooks). So `start-brief` never reads `passes` to\n decide: on every call it reads `GET /v1/payments/charges?status=succeeded` as the visitor (bound to\n the verified principal; no key a browser holds can write the ledger) and needs a succeeded, unrefunded\n charge from the last 365 days. The pass row is what the page *shows*; the ledger is what it *checks*.\n- **`correlation_id` closes the loop.** `start-brief` sends the session row id as `correlation_id` on\n `POST /v1/ai/generate` (`mode: 'job'`); the platform echoes it on `job.generation.completed` /\n `job.generation.failed` next to `generation_id`, so `settle-brief` finds its own row from the event alone.\n On `failed` it writes **`error_hint`** \u2014 the provider's own status and message \u2014 onto the row, so the\n visitor sees *why* (and `start-brief` accepts that row again, so a retry is one more click); on\n `completed` the text comes from the generation's replay buffer\n (`GET /v1/ai/generations/{generation_id}/stream?since=0`). Every settle write carries\n `if: { generation_id }`, so a late delivery for an earlier attempt can never overwrite a newer one; and a\n settle that cannot complete marks the row `failed` with `settle failed: <reason>` instead of answering a\n status nothing will act on \u2014 the row, not the delivery, is where recovery lives.\n- **The claim comes before the spend.** `start-brief` reads the row's `version`, PATCHes\n `result_status: 'queued'` with `If-Match: <version>`, and only then calls generate. A double click that\n lands inside the generate round-trip loses the compare-and-set with `409 version_conflict` and gets a\n `202 already_queued` \u2014 one generation, one credit reserve. A claim whose generate never recorded, or a\n queued handle older than the job lane's ceiling, is treated as failed on the next click, so a row can\n never stick in `queued`. Every write after the claim (recording the handle, marking a failure) carries\n `If-Match: <the claim's version>`, so an attempt the next click already swept can never overwrite the\n newer claim \u2014 its `409` means \"the row moved on\", and it steps aside without a write.\n- **Function errors are coded.** Every refusal a function answers is `{ error: { code, message } }` \u2014\n `POST /v1/fn/:name` hands that body back verbatim, and the SDK raises it as a `VxilError` whose `.code`\n is the function's own (`pass_required`, `intake_incomplete`, `already_settled`), so a page branches on a\n code, never on a message. The platform-delivered functions answer `200 { skipped }` for a decision, and a\n `500` with a code (`unsettled`, `grant_unsettled`) only when not even the row could record the failure \u2014\n not to be retried (nothing re-delivers it) but to be *seen* through `functions.run.failed`.\n- **Failure alerts are on.** `webhooks.alerts` mails the project's owner accounts every 15 minutes with the\n error-level failure events \u2014 a dead delivery, a failed generation, a missed schedule. Recipients are\n deliberately not configurable; `digestMinutes: 0` makes it immediate.\n- **The payments feature is an integration.** Your Stripe, Paddle, PayPal or RevenueCat account, the\n provider's hosted checkout, the provider's webhook; vxil folds the events and keeps the ledger. The\n scaffold's `mock` provider stops at the paywall, so the paid path runs on your provider's sandbox \u2014 the\n next section. To rehearse the grant chain without a card, Paddle's simulator signs with your real endpoint\n secret and your `payments.` functions run for real (vxil.com/docs/guide/06-feature-catalog).\n\n## Server side: keep the visitor's token fresh\n\nA session token lives `session.ttlMinutes` (60 here); the cookie your app sets lives days. Every sign-in\nreturns a pair \u2014 the token and a `refresh_token` good for `session.refreshTtlDays` (30) \u2014 and\n`vx.auth.sessions.ensureFresh(pair)` rotates it a few minutes before it expires (`POST /v1/auth/sessions/refresh`\nunderneath). So a server-rendered app needs one root middleware, not a redesign. The full recipe, with its\n`refreshFailure()` classifier and the `markRefreshFailed()` marker, is in\nvxil.com/docs/guide/09-security-and-multitenancy (\"Keeping the end-user token fresh behind a long-lived cookie\"):\n\n```ts\n// once per request, before any loader runs\nlet asUser = null;\nconst pair = readSessionCookie(request); // { token, refresh_token, expires_at }\nif (pair) {\n try {\n const { session, refreshed } = await vx.auth.sessions.ensureFresh(pair);\n if (refreshed) setSessionCookie(response, session); // rotated: store BOTH halves again\n asUser = vx.asEndUser(session.token); // every per-visitor read below is scoped by the platform\n } catch (e) {\n // NOT \"sign in again\" on any failure \u2014 see below. refreshFailure() is guide 09's classifier.\n const failure = refreshFailure(e, pair, readCookie(request, 'vx_refresh_failed'));\n if (failure === 'dead') clearSessionCookie(response); // revoked or expired: sign in again\n if (failure === 'race') markRefreshFailed(response, pair); // keep the cookie: the winner's is on its way\n // 'transient': keep the cookie; this one request runs signed out\n }\n}\n```\n\n**A failed refresh is not always a dead session.** Two requests carrying the same pair can reach two server\ninstances: one rotates, the other re-sends the refresh token that was revoked a moment ago and gets\n`401 invalid_refresh` \u2014 the same answer a truly revoked or expired token gets. Clearing the cookie on *any*\nfailure lets that loser's response overwrite the winner's new cookie, and a signed-in visitor is signed out for\nnothing. So the classifier keeps the cookie for anything that is not `invalid_refresh` (a network failure, a\ntimeout, a `429` or `5xx`), treats the first `invalid_refresh` of a pair as a lost race (a short-lived marker names\nthe pair), and clears the cookie only when the *same* pair fails again after a 15-second grace, or the cookie holds\nno refresh token at all.\n\nStore the refresh token only in the `HttpOnly` cookie; refresh *early* rather than on `401`, so parallel\nloaders in one process share one rotation; keep the server key for the tenant-wide rows only.\n\nThe same server owns the **checkout** \u2014 `POST /v1/payments/checkout-sessions` needs `payments:write`, which\na public key cannot carry, and it is server-only (a `403 server_only` in end-user mode). Three rules shape it:\n\n- **The buyer is the session, never a form field.** Ask the platform whose token it is:\n `vx.auth.sessions.verify` answers with the account *as it is now* (`auth:read` on the server key).\n- **A guest cannot buy \u2014 claim first.** A charge stays with the user id that paid it. When a guest claims an\n address that already has an account, the merge re-keys the guest's subscriptions, customer links and\n credit balances onto that account (vxil.com/docs/guide/06-feature-catalog, \"What a merge moves\") \u2014 not its\n one-off charges \u2014 and `start-brief` decides from the charges ledger. A guest who paid and *then* merged\n would have paid for a brief the merged account can never start. So refuse the checkout until the claim:\n `verified` is `false` while the visitor has no proven address \u2014 a guest has none, and the claim *is* the\n proof (every other way into this blueprint, a magic link or a code, proves one too). Not the token's\n `anon` claim: that is a snapshot from when the token was minted, not the account as it is now.\n- **One Idempotency-Key per purchase *attempt*, never per visitor.** A key the payments feature has seen\n replays its first session verbatim, and the key never expires \u2014 so `pass:${user_id}` would hand a visitor\n whose checkout expired, who was refunded, or who comes back after the 365-day pass the same dead URL\n forever. Key the attempt: the buy page renders a fresh attempt id into its form, so a double click posts\n the same id (one session), while a reload, a return from the checkout or next year's renewal renders a\n new one. The id is not a credential \u2014 the `user_id` next to it is the verified one, so an invented id\n only ever opens another checkout for the visitor's own account.\n\n```ts\n// the visitor is signed in (the middleware above keeps `session` fresh); the form posts `attempt`\nconst who = await vx.auth.sessions.verify(session.token); // { user_id, verified, \u2026 } \u2014 the account NOW\nif (!who.verified) { // still a guest: charges never follow a merge\n return Response.json({ error: { code: 'claim_required', message: 'claim your e-mail before you buy' } }, { status: 409 });\n}\nconst attempt = String(form.get('attempt') ?? ''); // crypto.randomUUID(), rendered with the buy page\nif (!/^[\\w-]{16,64}$/.test(attempt)) {\n return Response.json({ error: { code: 'bad_attempt', message: 'reload the page and try again' } }, { status: 400 });\n}\nconst { url } = await vx.payments.createCheckoutSession(\n { user_id: who.user_id, mode: 'payment',\n line_items: [{ price_ref: 'studio-pass', amount_cents: 4900 }], // a real provider takes ITS price id \u2014 below\n success_url: 'https://studio.example/thanks', cancel_url: 'https://studio.example/pass' },\n { idempotencyKey: `pass:${who.user_id}:${attempt}` },\n);\n// redirect the visitor to `url` \u2014 a hosted checkout on YOUR provider; vxil is never in the flow of funds.\n```\n\nThe same server is where the declared rate-limit policies are checked. `POST /v1/rate-limits/check` takes a\n`policy_id`, never a name \u2014 list the policies once after the first push and keep the two ids:\n\n```ts\nconst policies = await vx.rateLimits.listPolicies(); // RateLimitPolicy[] \u2014 \u2264100, no cursor\nconst stepPolicy = policies.find((p) => p.name === 'funnel-step')!.policy_id;\n// before forwarding a step write\nawait vx.rateLimits.check({ policy_id: stepPolicy, key_values: { user_id }, cost: 1 }); // 429 + Retry-After when blocked\n// before forwarding a claim request \u2014 a HASH of the address, never the address (it becomes a key)\nawait vx.rateLimits.check({ policy_id: claimPolicy, key_values: { email_hash: sha256(email) } });\n```\n\n## Running the paid path\n\nThe scaffold ships `payments.provider: 'mock'`, so `vxil push` works with no provider account, and\neverything but the payment runs on it: the intake, the claim, the deploy, and every refusal\n(`402 pass_required`, `409 intake_incomplete`, a guest's `claim_required`). What `mock` cannot do is put a\n**paid** charge on the ledger: its checkout URL is a placeholder host nothing serves, its webhooks are\nsigned with the platform's own secret rather than one you hold, and `vx.payments.simulate` scripts\n*subscription* lifecycles, not a one-off charge. So on `mock`, `grant-pass` never wakes and `start-brief`\nanswers `402 pass_required` to every visitor \u2014 the paywall doing its job. To run the brief end to end,\npoint the block at your provider's sandbox, in a project of its own (the signing-secret ref is one per\nprovider \u2014 vxil.com/docs/guide/06-feature-catalog):\n\n```ts\n// vxil.config.ts \u2014 a Stripe TEST-mode account (a Paddle sandbox is `provider: 'paddle'` + `paddle: { \u2026 }`)\npayments: {\n enabled: true, provider: 'stripe', defaults: { currency: 'usd' },\n stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },\n},\n```\n\n```bash\nvxil secrets set payments/stripe_secret # the test-mode secret key, at the hidden prompt\nvxil secrets set payments/stripe_webhook # the signing secret of the endpoint below\nvxil push\n```\n\nRegister `https://api.vxil.com/v1/internal/payments/webhook/stripe/<tenantId>` as the endpoint in the\nprovider's dashboard. On a real provider `line_items[].price_ref` is the provider's own price id (Stripe\n`price_\u2026`), not `studio-pass`. Pay with the provider's test card: the charge folds \u2192\n`payments.charge.succeeded` \u2192 `grant-pass` records the pass, and the next `start-brief` queues the brief.\n\n**Go deeper:** vxil.com/docs/guide/09-security-and-multitenancy (end-user mode \xB7 `strictEndUserScope` \xB7 the\nrefresh middleware) \xB7 vxil.com/docs/guide/06-feature-catalog (auth guests and claims \xB7 rate-limits \xB7\nwebhooks alerts and declared subscriptions \xB7 ai `mode: 'job'` \xB7 payments) \xB7\nvxil.com/docs/guide/08-running-your-code-functions (the `webhook` trigger \xB7 at-least-once) \xB7\nvxil.com/docs/guide/04-data-with-cms (owner scoping \xB7 the query DSL).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
17969
+ "readme": "# Funnel SaaS \u2014 the studio starter (saas)\n\nThe **web-first funnel** blueprint: a visitor lands, starts as a **guest**, walks a guided intake,\n**claims** the session with an e-mail code, buys a **pass** through your own payments provider,\nand gets an AI-written brief **settled back onto their record** \u2014 all of it declared in one typed\n`vxil.config.ts`, with three small functions for the parts that are code.\n\nIt is the shape most \"studio\" products have \u2014 a lead magnet, a paid deliverable, an account that\nonly exists once someone cares \u2014 and it is also the blueprint that shows **declared API state**:\nrate-limit policies, an outbound subscription and a stored prompt template live in the config and\nconverge on `vxil push`, so a second environment is a push, not a runbook.\n\n```bash\nvxil init --template funnel-saas\nvxil quickstart --env staging --no-push # or `vxil link <slug> --env staging` for an existing backend\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections + hooks + the three functions + the declared API state\nvxil gen # typed SDK: vx.from('funnel_sessions'), vx.fn['start-brief']\n```\n\n> **Plan note.** The three functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What it provisions:**\n- `funnel_sessions` \u2014 `user` (the **owner field**), `status` (`in_progress \u2192 completed`, hook-guarded),\n `step` (1\u20134, forward only), `answers` (json), then the brief's `generation_id`, `result_status`\n (`queued | completed | failed`), `result` and `error_hint`.\n- `passes` \u2014 `user` (owner), `charge_id` (**unique** \u2014 the dedupe anchor), `product`, `amount_cents`,\n `granted_at`, `expires_at`. The record the page shows \u2014 the payments ledger is the entitlement.\n- Features: `auth` (guest + OTP claim + a refreshable session pair) \xB7 `cms` (**`strictEndUserScope: true`**) \xB7\n `rate-limits` (two **declared policies**) \xB7 `webhooks` (**`alerts` on** + one **declared subscription**) \xB7\n `ai` (one **declared template**) \xB7 `jobs` \xB7 `payments` (a payments **integration** \u2014 your provider; `mock`\n here, which runs everything *up to* the paywall \u2014 [Running the paid path](#running-the-paid-path)) \xB7\n `notifications` (`mock`) \xB7 `functions`.\n- Functions: `start-brief` (http, invoked as the visitor), `grant-pass` (webhook on\n `payments.charge.succeeded`), `settle-brief` (webhook on the `job.generation.` prefix).\n\n> **A simpler pass, if your pass is a tier for a number of days.** Since 2026-09-25 a\n> `ledger.productMap` entry may be `{ tier: 'pass', durationDays: 30 }`: the platform then writes\n> the pass itself on the charge event (stacking behind a live one, ended by a full refund or\n> chargeback), and `GET /v1/payments/entitlements` answers \"is this visitor on a pass, until when\".\n> This blueprint keeps its own `passes` record and `grant-pass` function on purpose \u2014 its pass is a\n> *count of briefs*, decided from the owner-bound charge ledger, not a tier for a duration.\n\n## The funnel, step by step\n\nEvery call below is the browser talking to your backend \u2014 with **two keys**, the way\nvxil.com/docs/guide/09-security-and-multitenancy prescribes: a **sign-in key** (the ordinary class,\ncarrying only `auth:signin`) for the moment *before* there is a session, and a **thin-client key**\n(`key_class: 'public'` \u2014 `cms:read`, `cms:write`, `functions:invoke`) for everything after, which the\nedge refuses unless it rides with a valid end-user token. A public key *cannot* carry `payments:write`\n(a `422` at mint), so the checkout is the one call your server makes \u2014 the last section.\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nconst signin = Vxil.connect({ apiKey: SIGNIN_KEY }); // auth:signin only \u2014 works with no session\nconst thin = Vxil.connect({ apiKey: THIN_CLIENT_KEY }); // public class \u2014 needs a session on every call\n\n// 1. A guest. Real user id, real session, no e-mail yet (the token carries `anon: true`).\nconst guest = await signin.auth.anonymous.signIn();\nconst me = thin.asEndUser(guest.session.token);\n\n// 2. The intake. Owner-scoped: the platform stamps `user` with the guest's id on create,\n// and this session (and only this session) can read and step the row.\nconst { item_id: session_id } = await me.from('funnel_sessions').create({\n status: 'in_progress', step: 1, started_at: new Date().toISOString(),\n answers: { goal: 'Launch a coffee subscription' },\n});\nawait me.from('funnel_sessions').patch(session_id, { step: 2, answers: { goal: '\u2026', audience: '\u2026' } });\n// \u2026steps 3 and 4. A PATCH that moves `step` backwards is a 422 from the `steps_forward_only` hook.\n\n// 3. The claim. A 6-digit code proves the address; the guest is PROMOTED IN PLACE \u2014\n// same user id, so the intake rows are already theirs. (Sign-in class: the sign-in key.)\nawait signin.auth.anonymous.link.request({ token: guest.session.token, email: 'ada@example.com' });\nconst claimed = await signin.auth.anonymous.link.verify({ token: guest.session.token, email: 'ada@example.com', code: '123456' });\n// claimed.user_id === guest.user_id \u2014 unless the address already had an account: then\n// `merged: true`, a fresh session, and `rekeyed` says whether the guest's rows moved already.\n\n// 4. The pass \u2014 only after the claim. Your SERVER refuses a guest, creates the hosted checkout\n// (below) and hands the URL back; the provider's webhook folds the charge \u2192\n// `payments.charge.succeeded` \u2192 grant-pass writes the pass record.\n\n// 5. The brief. Invoked AS THE VISITOR: the function's reads are owner-scoped and the\n// generation is metered to them. Until the visitor's ledger holds a paid charge the call\n// throws a VxilError whose `.code` is 'pass_required' (402); an unfinished intake is\n// 'intake_incomplete' (409).\nconst handle = await me.fn['start-brief']({ session_id });\n// \u2192 { generation_id, run_id, correlation_id } for a new brief, or \u2014 a second click while one\n// is running \u2014 { generation_id, status: 'already_queued' }: one generation, metered once,\n// and `generation_id` is null while that attempt has not recorded its handle yet (the\n// declared signature is that union). Either way, watch the ROW, not the handle:\n// \u2026seconds to minutes later, settle-brief writes `result` (or `error_hint`) onto the row and\n// notifications delivers \"Your brief is ready\". A failed brief can be started again: the\n// same call on a row whose result_status is 'failed' queues a new generation.\n```\n\n## What to learn from this\n\n- **`strictEndUserScope: true` is the fail-safe version of owner scoping.** Both collections declare an\n `ownerField`, so a signed-in visitor reaches exactly their own rows; a collection you add later *without*\n one is `403 server_only` for end-users on read and write instead of tenant-wide-shared. Nothing about a\n server call changes \u2014 a server key still sees everything\n (vxil.com/docs/guide/09-security-and-multitenancy).\n- **A guest is a real end-user.** `vx.auth.anonymous.signIn()` mints a real id; owner-scoped rows written\n before the claim stay attached because the OTP claim (`POST /v1/auth/anonymous/link/request` + `/verify`)\n promotes the guest in place. Passing the guest's token as `anonymous_token` on a magic-link request does\n the same through mail (vxil.com/docs/guide/06-feature-catalog).\n- **Declared API state.** Three things that used to be rows you created by hand \u2014 and re-created in every\n environment \u2014 are config now, and `vxil push` converges the live rows onto them, reporting (never\n deleting) what you did not declare:\n\n | Block | Converged by | The live rows it replaces |\n |---|---|---|\n | `features['rate-limits'].policies[]` | `name` | `POST /v1/rate-limits/policies` |\n | `features.webhooks.subscriptions[]` | `target_url` | `POST /v1/webhooks/subscriptions` |\n | `features.ai.templates[]` | content hash | `POST /v1/ai/templates` (a new version only when the text changed) |\n\n The three function triggers are *not* declared here \u2014 a `webhook`-trigger binding on a function is its\n own declaration, and the platform reconciles that subscription from the functions manifest.\n- **An event-driven grant, done honestly.** `grant-pass` wakes on the full event name\n `payments.charge.succeeded` a few seconds after your provider's webhook folds. The event name is a\n *routing claim*, so the function re-reads the ledger (`GET /v1/payments/charges`) before writing; the\n `passes.charge_id` unique field (the ledger's charge id) turns an at-least-once redelivery into a `409`\n it reads as \"already granted\". And it is honest about **what is not retried**: the dispatcher ACKs a\n platform-delivered function with a `200` *whatever it answered*, so a `5xx` from the API is never\n re-delivered \u2014 it is *observed* (`functions.run.failed`, once per failure streak, in the\n `webhooks.alerts` digest), and recovery is data, not delivery: `start-brief` decides from the same\n owner-bound ledger on every request, so a lost grant never blocks a payer, and it writes the missing\n pass record for that charge (a `409` there just means it is recorded already)\n (vxil.com/docs/guide/08-running-your-code-functions).\n- **An owned row the visitor can write is never an entitlement.** `passes` declares an `ownerField`, so\n the thin-client key (`cms:write`) lets a visitor *create* a pass of their own or PATCH its\n `expires_at` to 2099 \u2014 the platform stamps them as the owner, which proves who wrote the row, not that\n anyone paid, and a write hook's `caller` is always `null`, so no hook can tell that write from\n `grant-pass`'s (vxil.com/docs/guide/07-validation-and-hooks). So `start-brief` never reads `passes` to\n decide: on every call it reads `GET /v1/payments/charges?status=succeeded` as the visitor (bound to\n the verified principal; no key a browser holds can write the ledger) and needs a succeeded, unrefunded\n charge from the last 365 days. The pass row is what the page *shows*; the ledger is what it *checks*.\n- **`correlation_id` closes the loop.** `start-brief` sends the session row id as `correlation_id` on\n `POST /v1/ai/generate` (`mode: 'job'`); the platform echoes it on `job.generation.completed` /\n `job.generation.failed` next to `generation_id`, so `settle-brief` finds its own row from the event alone.\n On `failed` it writes **`error_hint`** \u2014 the provider's own status and message \u2014 onto the row, so the\n visitor sees *why* (and `start-brief` accepts that row again, so a retry is one more click); on\n `completed` the text comes from the generation's replay buffer\n (`GET /v1/ai/generations/{generation_id}/stream?since=0`). Every settle write carries\n `if: { generation_id }`, so a late delivery for an earlier attempt can never overwrite a newer one; and a\n settle that cannot complete marks the row `failed` with `settle failed: <reason>` instead of answering a\n status nothing will act on \u2014 the row, not the delivery, is where recovery lives.\n- **The claim comes before the spend.** `start-brief` reads the row's `version`, PATCHes\n `result_status: 'queued'` with `If-Match: <version>`, and only then calls generate. A double click that\n lands inside the generate round-trip loses the compare-and-set with `409 version_conflict` and gets a\n `202 already_queued` \u2014 one generation, one credit reserve. A claim whose generate never recorded, or a\n queued handle older than the job lane's ceiling, is treated as failed on the next click, so a row can\n never stick in `queued`. Every write after the claim (recording the handle, marking a failure) carries\n `If-Match: <the claim's version>`, so an attempt the next click already swept can never overwrite the\n newer claim \u2014 its `409` means \"the row moved on\", and it steps aside without a write.\n- **Function errors are coded.** Every refusal a function answers is `{ error: { code, message } }` \u2014\n `POST /v1/fn/:name` hands that body back verbatim, and the SDK raises it as a `VxilError` whose `.code`\n is the function's own (`pass_required`, `intake_incomplete`, `already_settled`), so a page branches on a\n code, never on a message. The platform-delivered functions answer `200 { skipped }` for a decision, and a\n `500` with a code (`unsettled`, `grant_unsettled`) only when not even the row could record the failure \u2014\n not to be retried (nothing re-delivers it) but to be *seen* through `functions.run.failed`.\n- **Failure alerts are on.** `webhooks.alerts` mails the project's owner accounts every 15 minutes with the\n error-level failure events \u2014 a dead delivery, a failed generation, a missed schedule. Recipients are\n deliberately not configurable; `digestMinutes: 0` makes it immediate.\n- **The payments feature is an integration.** Your Stripe, Paddle, PayPal or RevenueCat account, the\n provider's hosted checkout, the provider's webhook; vxil folds the events and keeps the ledger. The\n scaffold's `mock` provider stops at the paywall, so the paid path runs on your provider's sandbox \u2014 the\n next section. To rehearse the grant chain without a card, Paddle's simulator signs with your real endpoint\n secret and your `payments.` functions run for real (vxil.com/docs/guide/06-feature-catalog).\n\n## Server side: keep the visitor's token fresh\n\nA session token lives `session.ttlMinutes` (60 here); the cookie your app sets lives days. Every sign-in\nreturns a pair \u2014 the token and a `refresh_token` good for `session.refreshTtlDays` (30) \u2014 and\n`vx.auth.sessions.ensureFresh(pair)` rotates it a few minutes before it expires (`POST /v1/auth/sessions/refresh`\nunderneath). So a server-rendered app needs one root middleware, not a redesign. The full recipe, with its\n`refreshFailure()` classifier and the `markRefreshFailed()` marker, is in\nvxil.com/docs/guide/09-security-and-multitenancy (\"Keeping the end-user token fresh behind a long-lived cookie\"):\n\n```ts\n// once per request, before any loader runs\nlet asUser = null;\nconst pair = readSessionCookie(request); // { token, refresh_token, expires_at }\nif (pair) {\n try {\n const { session, refreshed } = await vx.auth.sessions.ensureFresh(pair);\n if (refreshed) setSessionCookie(response, session); // rotated: store BOTH halves again\n asUser = vx.asEndUser(session.token); // every per-visitor read below is scoped by the platform\n } catch (e) {\n // NOT \"sign in again\" on any failure \u2014 see below. refreshFailure() is guide 09's classifier.\n const failure = refreshFailure(e, pair, readCookie(request, 'vx_refresh_failed'));\n if (failure === 'dead') clearSessionCookie(response); // revoked or expired: sign in again\n if (failure === 'race') markRefreshFailed(response, pair); // keep the cookie: the winner's is on its way\n // 'transient': keep the cookie; this one request runs signed out\n }\n}\n```\n\n**A failed refresh is not always a dead session.** Two requests carrying the same pair can reach two server\ninstances: one rotates, the other re-sends the refresh token that was revoked a moment ago and gets\n`401 invalid_refresh` \u2014 the same answer a truly revoked or expired token gets. Clearing the cookie on *any*\nfailure lets that loser's response overwrite the winner's new cookie, and a signed-in visitor is signed out for\nnothing. So the classifier keeps the cookie for anything that is not `invalid_refresh` (a network failure, a\ntimeout, a `429` or `5xx`), treats the first `invalid_refresh` of a pair as a lost race (a short-lived marker names\nthe pair), and clears the cookie only when the *same* pair fails again after a 15-second grace, or the cookie holds\nno refresh token at all.\n\nStore the refresh token only in the `HttpOnly` cookie; refresh *early* rather than on `401`, so parallel\nloaders in one process share one rotation; keep the server key for the tenant-wide rows only.\n\nThe same server owns the **checkout** \u2014 `POST /v1/payments/checkout-sessions` needs `payments:write`, which\na public key cannot carry, and it is server-only (a `403 server_only` in end-user mode). Three rules shape it:\n\n- **The buyer is the session, never a form field.** Ask the platform whose token it is:\n `vx.auth.sessions.verify` answers with the account *as it is now* (`auth:read` on the server key).\n- **A guest cannot buy \u2014 claim first.** A charge stays with the user id that paid it. When a guest claims an\n address that already has an account, the merge re-keys the guest's subscriptions, customer links and\n credit balances onto that account (vxil.com/docs/guide/06-feature-catalog, \"What a merge moves\") \u2014 not its\n one-off charges \u2014 and `start-brief` decides from the charges ledger. A guest who paid and *then* merged\n would have paid for a brief the merged account can never start. So refuse the checkout until the claim:\n `verified` is `false` while the visitor has no proven address \u2014 a guest has none, and the claim *is* the\n proof (every other way into this blueprint, a magic link or a code, proves one too). Not the token's\n `anon` claim: that is a snapshot from when the token was minted, not the account as it is now.\n- **One Idempotency-Key per purchase *attempt*, never per visitor.** A key the payments feature has seen\n replays its first session verbatim, and the key never expires \u2014 so `pass:${user_id}` would hand a visitor\n whose checkout expired, who was refunded, or who comes back after the 365-day pass the same dead URL\n forever. Key the attempt: the buy page renders a fresh attempt id into its form, so a double click posts\n the same id (one session), while a reload, a return from the checkout or next year's renewal renders a\n new one. The id is not a credential \u2014 the `user_id` next to it is the verified one, so an invented id\n only ever opens another checkout for the visitor's own account.\n\n```ts\n// the visitor is signed in (the middleware above keeps `session` fresh); the form posts `attempt`\nconst who = await vx.auth.sessions.verify(session.token); // { user_id, verified, \u2026 } \u2014 the account NOW\nif (!who.verified) { // still a guest: charges never follow a merge\n return Response.json({ error: { code: 'claim_required', message: 'claim your e-mail before you buy' } }, { status: 409 });\n}\nconst attempt = String(form.get('attempt') ?? ''); // crypto.randomUUID(), rendered with the buy page\nif (!/^[\\w-]{16,64}$/.test(attempt)) {\n return Response.json({ error: { code: 'bad_attempt', message: 'reload the page and try again' } }, { status: 400 });\n}\nconst { url } = await vx.payments.createCheckoutSession(\n { user_id: who.user_id, mode: 'payment',\n line_items: [{ price_ref: 'studio-pass', amount_cents: 4900 }], // a real provider takes ITS price id \u2014 below\n success_url: 'https://studio.example/thanks', cancel_url: 'https://studio.example/pass' },\n { idempotencyKey: `pass:${who.user_id}:${attempt}` },\n);\n// redirect the visitor to `url` \u2014 a hosted checkout on YOUR provider; vxil is never in the flow of funds.\n```\n\nThe same server is where the declared rate-limit policies are checked. `POST /v1/rate-limits/check` takes a\n`policy_id`, never a name \u2014 list the policies once after the first push and keep the two ids:\n\n```ts\nconst policies = await vx.rateLimits.listPolicies(); // RateLimitPolicy[] \u2014 \u2264100, no cursor\nconst stepPolicy = policies.find((p) => p.name === 'funnel-step')!.policy_id;\n// before forwarding a step write\nawait vx.rateLimits.check({ policy_id: stepPolicy, key_values: { user_id }, cost: 1 }); // 429 + Retry-After when blocked\n// before forwarding a claim request \u2014 a HASH of the address, never the address (it becomes a key)\nawait vx.rateLimits.check({ policy_id: claimPolicy, key_values: { email_hash: sha256(email) } });\n```\n\n## Running the paid path\n\nThe scaffold ships `payments.provider: 'mock'`, so `vxil push` works with no provider account, and\neverything but the payment runs on it: the intake, the claim, the deploy, and every refusal\n(`402 pass_required`, `409 intake_incomplete`, a guest's `claim_required`). What `mock` cannot do is put a\n**paid** charge on the ledger: its checkout URL is a placeholder host nothing serves, its webhooks are\nsigned with the platform's own secret rather than one you hold, and `vx.payments.simulate` scripts\n*subscription* lifecycles, not a one-off charge. So on `mock`, `grant-pass` never wakes and `start-brief`\nanswers `402 pass_required` to every visitor \u2014 the paywall doing its job. To run the brief end to end,\npoint the block at your provider's sandbox, in a project of its own (the signing-secret ref is one per\nprovider \u2014 vxil.com/docs/guide/06-feature-catalog):\n\n```ts\n// vxil.config.ts \u2014 a Stripe TEST-mode account (a Paddle sandbox is `provider: 'paddle'` + `paddle: { \u2026 }`)\npayments: {\n enabled: true, provider: 'stripe', defaults: { currency: 'usd' },\n stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },\n},\n```\n\n```bash\nvxil secrets set payments/stripe_secret # the test-mode secret key, at the hidden prompt\nvxil secrets set payments/stripe_webhook # the signing secret of the endpoint below\nvxil push\n```\n\nRegister `https://api.vxil.com/v1/internal/payments/webhook/stripe/<tenantId>` as the endpoint in the\nprovider's dashboard. On a real provider `line_items[].price_ref` is the provider's own price id (Stripe\n`price_\u2026`), not `studio-pass`. Pay with the provider's test card: the charge folds \u2192\n`payments.charge.succeeded` \u2192 `grant-pass` records the pass, and the next `start-brief` queues the brief.\n\n**Go deeper:** vxil.com/docs/guide/09-security-and-multitenancy (end-user mode \xB7 `strictEndUserScope` \xB7 the\nrefresh middleware) \xB7 vxil.com/docs/guide/06-feature-catalog (auth guests and claims \xB7 rate-limits \xB7\nwebhooks alerts and declared subscriptions \xB7 ai `mode: 'job'` \xB7 payments) \xB7\nvxil.com/docs/guide/08-running-your-code-functions (the `webhook` trigger \xB7 at-least-once) \xB7\nvxil.com/docs/guide/04-data-with-cms (owner scoping \xB7 the query DSL).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
18318
17970
  "functions": {
18319
- "grant-pass.ts": "// grant-pass.ts \u2014 GRANT A PASS ON A SUCCESSFUL CHARGE (a vxil function, \xA77.3).\n//\n// Trigger: webhook, source 'payments.charge.succeeded' \u2014 the FULL event name, so\n// this function wakes for exactly one event: the moment your provider's webhook\n// (Stripe, Paddle, PayPal, RevenueCat, or the mock) folds a succeeded charge.\n// The platform wires the subscription for you on deploy; the first attempt is\n// delivered directly, a few seconds after the event.\n//\n// The envelope's `payload` is the event, flattened:\n// { event, audit_id, occurred_at, actor, surface, data }\n// and `data` is the payments.charge.succeeded payload:\n// { end_user_id, provider, provider_charge_id, amount_cents, currency, product_id, environment }\n//\n// THREE RULES this function lives by:\n// \u2022 The event NAME is a routing claim, not an attestation \u2014 anyone who can\n// enqueue a job in your own project can produce a delivery with a chosen\n// name. So the ledger is re-read (GET /v1/payments/charges) before a pass\n// is written; a charge the ledger does not know is skipped.\n// \u2022 Delivery is at-least-once. `passes.charge_id` is UNIQUE (the LEDGER's\n// charge id \u2014 the same key start-brief's record write uses), so a redelivery\n// is a clean 409 unique_violation treated as \"already granted\".\n// \u2022 Nothing this function ANSWERS is re-delivered. The dispatcher ACKs every\n// platform-delivered trigger with a 200 whatever the handler returned\n// (guide 08, \"What is retried, and what is not\") \u2014 so a 5xx here is not a\n// retry request, it is the OBSERVATION channel: `functions.run.failed`\n// (once per failure streak) \u2192 the `webhooks.alerts` digest. Recovery is\n// data, not delivery: the pass row is a DISPLAY RECORD (an owned row the\n// visitor's own key could write, so it is never the entitlement) \u2014\n// start-brief decides from the same owner-bound ledger on every request,\n// so a grant lost here never blocks a payer, and it re-writes the missing\n// record. A decision (skip) answers 200 and is a decision.\n\nimport type { WebhookFunctionEnvelope } from '@vxil/sdk';\n\n/** the `payments.charge.succeeded` event payload (the fields read here) */\ninterface ChargeSucceeded {\n end_user_id?: string | null;\n provider_charge_id?: string | null;\n amount_cents?: number | null;\n currency?: string | null;\n product_id?: string | null;\n}\ntype Env = WebhookFunctionEnvelope<ChargeSucceeded>;\ninterface Charge { charge_id: string; amount_cents: number; currency: string; status: string }\ninterface ChargeList { data?: { charges?: Charge[] } }\ninterface Created { data?: { item_id?: string } }\ninterface ErrBody { error?: { code?: string } }\n\nconst PASS_DAYS = 365; // mirrored in start-brief.ts (the ledger gate)\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const pay = env.scoped_jwts?.payments;\n const cms = env.scoped_jwts?.cms;\n if (!pay || !cms) return fail(403, 'missing_scopes', 'the function needs payments:read and cms:write');\n // The binding's `source` already filters deliveries; this stays as belt-and-braces.\n if (env.payload?.event !== 'payments.charge.succeeded') {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n const raw = env.payload.data;\n // Past 64 KiB the whole `data` value is replaced by { truncated: true } \u2014\n // never a half object. A charge payload is tiny, but the check is free.\n if (raw && 'truncated' in raw) return Response.json({ skipped: true, reason: 'payload truncated' });\n const data: ChargeSucceeded = raw ?? {};\n const userId = data.end_user_id ?? null;\n const providerChargeId = data.provider_charge_id ?? null;\n if (!userId || !providerChargeId || data.amount_cents == null) {\n return Response.json({ skipped: true, reason: 'charge carries no user, id or amount' });\n }\n\n // 1. Re-read the LEDGER (the authoritative record) \u2014 a succeeded charge of\n // this amount must exist for this user. This is what turns the event\n // name from a claim into a fact. (One product, one pass per purchase:\n // the match is by amount; the list is newest-first.)\n const led = await fetch(\n `${base}/v1/payments/charges?user_id=${encodeURIComponent(userId)}&status=succeeded&limit=50`,\n { headers: H(pay) },\n ).catch(() => new Response(null, { status: 599 }));\n if (led.status >= 500) return unsettled(`ledger read ${led.status}`);\n if (!led.ok) return Response.json({ skipped: true, reason: `ledger read ${led.status}` });\n const charges = ((await led.json()) as ChargeList).data?.charges ?? [];\n const match = charges.find((c) => c.status === 'succeeded' && c.amount_cents === data.amount_cents\n && (!data.currency || c.currency === data.currency));\n if (!match) return Response.json({ skipped: true, reason: 'no succeeded charge of that amount on the ledger' });\n\n // 2. Write the pass RECORD. Server mode, so `user` is set explicitly (in\n // end-user mode the platform would stamp it). The ledger's `charge_id` is\n // the unique anchor \u2014 start-brief's record write keys on the same id.\n const now = Date.now();\n const created = await fetch(`${base}/v1/cms/items/passes`, {\n method: 'POST',\n headers: H(cms),\n body: JSON.stringify({\n data: {\n user: userId,\n charge_id: match.charge_id,\n product: data.product_id ?? 'studio-pass',\n amount_cents: data.amount_cents,\n granted_at: new Date(now).toISOString(),\n expires_at: new Date(now + PASS_DAYS * 86_400_000).toISOString(),\n },\n }),\n }).catch(() => new Response(null, { status: 599 }));\n if (created.status === 409) {\n const code = ((await created.json().catch(() => ({}))) as ErrBody).error?.code;\n // the redelivery case \u2014 the pass is already there\n return Response.json({ granted: false, reason: code ?? 'already granted', charge_id: match.charge_id });\n }\n if (created.status >= 500) return unsettled(`pass write ${created.status}`);\n if (!created.ok) return Response.json({ skipped: true, reason: `pass write ${created.status}` });\n const itemId = ((await created.json()) as Created).data?.item_id;\n return Response.json({\n granted: true, pass: itemId, user: userId, charge_id: match.charge_id, provider_charge_id: providerChargeId,\n });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst fail = (status: number, code: string, message: string) =>\n Response.json({ error: { code, message } }, { status });\n/** The charge succeeded and no pass was written. Answer 500 so the run is SEEN\n * (`functions.run.failed` \u2192 the alert digest); it is ACKed, never re-delivered\n * \u2014 start-brief decides from the ledger (the payer is never blocked) and\n * re-writes the missing record on the next request. */\nconst unsettled = (reason: string) =>\n fail(500, 'grant_unsettled', `${reason} \u2014 no pass written; surfaced via functions.run.failed (not re-delivered), repaired by start-brief from the ledger`);\n",
18320
- "settle-brief.ts": "// settle-brief.ts \u2014 SETTLE THE BRIEF ONTO ITS ROW (a vxil function, \xA77.3).\n//\n// Trigger: webhook, source 'job.generation.' \u2014 the PREFIX, so this function\n// wakes for both `job.generation.completed` and `job.generation.failed`. The\n// envelope's `payload.data` is the event payload:\n// { run_id, generation_id, correlation_id, status, error_class, error_hint, state, level }\n//\n// `correlation_id` is the funnel_sessions row id that start-brief sent with\n// the generate call \u2014 the platform echoes it on the event next to\n// generation_id, so this function needs no run\u2192record table of its own.\n//\n// On COMPLETED: the settled text lives in the generation's replay buffer\n// (GET /v1/ai/generations/{id}/stream?since=0 \u2014 every recorded frame, plus\n// `done`); the token frames are joined and PATCHed onto the row, then the\n// visitor gets a \"ready\" message. On FAILED: `error_hint` carries the\n// provider's own status and message (`Upstream said: \u2026`) and `error_class`\n// the class \u2014 the row gets the hint, so the visitor (and your support inbox)\n// sees WHY, not just that it failed. On completed both are null by contract.\n//\n// THE DELIVERY CONTRACT (guide 08, \"What is retried, and what is not\"): on\n// every platform-delivered trigger the dispatcher ACKs the delivery with a 200\n// WHATEVER this handler returns. A 5xx from here is never re-delivered \u2014 it is\n// OBSERVED (`functions.run.failed`, once per failure streak, digested by\n// `webhooks.alerts`). Only a crash of the dispatch itself is retried. So this\n// function never answers a 5xx hoping for a redelivery; it makes every failure\n// RECOVERABLE THROUGH THE ROW instead:\n// \u2022 a settle that cannot complete marks the row `result_status: 'failed'`\n// with `error_hint: 'settle failed: <reason>'` \u2014 start-brief accepts that\n// row again, so the visitor's retry is one click;\n// \u2022 only when not even that mark could be written does it answer 500 \u2014 not\n// for a retry (there is none) but so the failure is SEEN in the alert\n// digest instead of vanishing behind a 200.\n// The one transient it waits out ITSELF is the replay buffer: `done: false`\n// means the terminal frame is a moment behind the event, so the read is\n// repeated a few times before giving up (the guide's \"retry internally\").\n//\n// Every write carries `if: { generation_id }` \u2014 the row is touched only while\n// it is still waiting on THIS generation, so a late delivery for an earlier\n// attempt can never overwrite a newer one (409 precondition_failed = stale).\n// At-least-once: the same PATCH twice is the same row, and the \"ready\" send\n// carries the delivery's idempotency key, so a redelivery never double-mails.\n\nimport type { JobGenerationSettledEventPayload, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = WebhookFunctionEnvelope<JobGenerationSettledEventPayload>;\ninterface SessionData { user?: string; generation_id?: string | null; result_status?: string }\ninterface Item { data?: { data?: SessionData } }\ninterface Replay { data?: { frames?: Array<{ type?: string; delta?: string; done?: boolean }>; done?: boolean } }\n\nconst REPLAY_TRIES = 4; // reads of the replay buffer before giving up \u2026\nconst REPLAY_WAIT_MS = 500; // \u2026 and the pause between them (wall time, not CPU)\nconst ROW_TRIES = 3; // re-reads of a row still inside start-brief's claim\u2192generate\u2192record window\nconst ROW_WAIT_MS = 700;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const notif = env.scoped_jwts?.notifications;\n if (!cms || !ai) return fail(403, 'missing_scopes', 'the function needs cms:read, cms:write and ai:read');\n\n const event = env.payload?.event ?? '';\n if (event !== 'job.generation.completed' && event !== 'job.generation.failed') {\n return Response.json({ skipped: true, event }); // e.g. job.generation.processing\n }\n // a truncated payload ({ truncated: true }) carries nothing to settle from\n const raw = env.payload?.data;\n const data: Partial<JobGenerationSettledEventPayload> = raw && !('truncated' in raw) ? raw : {};\n const sessionId = data.correlation_id ?? null;\n const generationId = data.generation_id ?? null;\n if (!sessionId || !generationId) {\n return Response.json({ skipped: true, reason: 'not a funnel generation (no correlation_id)' });\n }\n const row = { base, cms, sessionId, generationId };\n\n // Find our row through the correlation id (server mode: any row). It must\n // be waiting on THIS generation: another id is a stale delivery (an earlier\n // attempt settling late) \u2014 unless the row is `queued` with no handle yet,\n // which is start-brief's claim\u2192generate\u2192record window: the handle is one\n // PATCH away, so the read is repeated before the delivery is called stale.\n let session: SessionData | null = null;\n let readStatus = 0;\n for (let i = 0; i < ROW_TRIES; i++) {\n if (i > 0) await sleep(ROW_WAIT_MS);\n const res = await fetch(`${base}/v1/cms/items/funnel_sessions/${encodeURIComponent(sessionId)}`, { headers: H(cms) });\n readStatus = res.status;\n if (res.status === 404) return Response.json({ skipped: true, reason: 'no such session' });\n if (!res.ok) continue; // a 5xx read: try again, then fall through to the blind mark below\n const s = ((await res.json()) as Item).data?.data ?? {};\n if (s.generation_id === generationId) { session = s; break; }\n if (s.result_status === 'queued' && !s.generation_id) continue; // claimed, handle not recorded yet\n return Response.json({ skipped: true, reason: 'stale generation', expected: s.generation_id ?? null });\n }\n if (!session) {\n if (readStatus >= 500) {\n // The row could not be read. Mark it failed anyway \u2014 the `if`\n // precondition lands the mark only if the row is still waiting on this\n // generation \u2014 and let the visitor retry.\n return await settleFailed(row, `session read ${readStatus}`);\n }\n return Response.json({ skipped: true, reason: 'stale generation (no handle recorded for it)' });\n }\n\n if (event === 'job.generation.failed') {\n // The provider's own words, bounded (the event caps the hint at 200 chars).\n const hint = data.error_hint ?? data.error_class ?? 'generation failed';\n const patch = await patchSession(row, { result_status: 'failed', error_hint: hint });\n if (patch.status === 409) return Response.json({ skipped: true, reason: 'stale generation' });\n if (!patch.ok) return unsettled(`session patch ${patch.status}`);\n return Response.json({ settled: 'failed', session: sessionId, error_class: data.error_class ?? null, error_hint: hint });\n }\n\n // COMPLETED \u2014 the text is in the replay buffer. `done: false` means the\n // buffer has not received its terminal frame yet \u2014 a moment behind the\n // event \u2014 so the read is repeated a few times before the brief is given\n // up (never settled empty; never a 5xx-and-hope: nothing re-delivers).\n let replay: NonNullable<Replay['data']> | null = null;\n let replayStatus = 0;\n for (let i = 0; i < REPLAY_TRIES; i++) {\n if (i > 0) await sleep(REPLAY_WAIT_MS);\n const rep = await fetch(`${base}/v1/ai/generations/${encodeURIComponent(generationId)}/stream?since=0`, { headers: H(ai) });\n replayStatus = rep.status;\n if (rep.status >= 400 && rep.status < 500) break; // not transient \u2014 a 404 generation will not appear\n if (!rep.ok) continue;\n const d = ((await rep.json()) as Replay).data ?? {};\n if (d.done) { replay = d; break; }\n }\n if (!replay) {\n return await settleFailed(row, replayStatus >= 400 ? `replay read ${replayStatus}` : 'replay buffer never reported done');\n }\n const text = (replay.frames ?? []).filter((f) => f.type === 'token').map((f) => f.delta ?? '').join('');\n\n const patch = await patchSession(row, { result_status: 'completed', result: text, error_hint: null });\n if (patch.status === 409) return Response.json({ skipped: true, reason: 'stale generation' });\n if (!patch.ok) return await settleFailed(row, `session patch ${patch.status}`);\n\n // Tell the visitor. Best-effort AFTER the result is on the row: a mail that\n // fails must not un-settle a brief that is ready. The delivery's\n // idempotency key rides the send, so a redelivered event never sends twice\n // (notifications honors Idempotency-Key).\n let delivery: number | null = null;\n if (notif && session.user) {\n const send = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { ...H(notif), ...(env.idempotency_key ? { 'idempotency-key': env.idempotency_key } : {}) },\n body: JSON.stringify({\n user_id: session.user,\n template: 'transactional',\n data: { subject: 'Your brief is ready', paragraph: 'Open the studio to read your creative brief.' },\n }),\n }).catch(() => null);\n delivery = send?.status ?? null;\n }\n return Response.json({ settled: 'completed', session: sessionId, chars: text.length, delivery });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\ninterface Row { base: string; cms: string; sessionId: string; generationId: string }\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));\nconst fail = (status: number, code: string, message: string) =>\n Response.json({ error: { code, message } }, { status });\n/** Not even the row could record the failure: answer 500 so the run is SEEN\n * (`functions.run.failed` \u2192 the alert digest). ACKed, never re-delivered. */\nconst unsettled = (reason: string) =>\n fail(500, 'unsettled', `${reason} \u2014 the row could not be marked failed; surfaced via functions.run.failed, not re-delivered`);\n/** Every write is guarded by `if: { generation_id }`: the row is touched only\n * while it still waits on THIS generation (409 precondition_failed = stale). */\nfunction patchSession(row: Row, data: Record<string, unknown>): Promise<Response> {\n return fetch(`${row.base}/v1/cms/items/funnel_sessions/${encodeURIComponent(row.sessionId)}`, {\n method: 'PATCH', headers: H(row.cms), body: JSON.stringify({ data, if: { generation_id: row.generationId } }),\n }).catch(() => new Response(null, { status: 599 }));\n}\n/** The recoverable outcome: mark the row failed with the reason, so start-brief\n * accepts it again and the visitor's retry is one click. */\nasync function settleFailed(row: Row, reason: string): Promise<Response> {\n const patch = await patchSession(row, { result_status: 'failed', error_hint: `settle failed: ${reason}`.slice(0, 200) });\n if (patch.ok) return Response.json({ settled: 'failed', session: row.sessionId, reason });\n if (patch.status === 409) return Response.json({ skipped: true, reason: 'stale generation' });\n return unsettled(`${reason}; mark failed \u2192 ${patch.status}`);\n}\n",
18321
- "start-brief.ts": "// start-brief.ts \u2014 START THE AI BRIEF (a vxil function, \xA77.3).\n//\n// Trigger: http \u2014 POST /v1/fn/start-brief, called by the browser AS THE\n// SIGNED-IN VISITOR (vx.asEndUser(session.token).fn['start-brief']({ session_id })).\n// The verified end-user principal rides into this function: `env.end_user` names\n// them, and every scoped token below carries the same principal, so the cms\n// reads are OWNER-SCOPED (a foreign session id is an honest 404), the charge\n// list is bound to them, and the generation is owned by \u2014 and metered to \u2014\n// that user, never a body field.\n//\n// What it does, in order:\n// 1. refuses a server-mode call (the funnel is a per-visitor flow);\n// 2. re-reads the session row (with its `version`) and requires a finished\n// intake \u2014 or a brief that FAILED, or a claim/queue that DIED (below);\n// 3. requires a PAID pass, decided by the owner-bound LEDGER on every call:\n// a succeeded, unrefunded charge from the last PASS_DAYS. Never by a\n// `passes` row \u2014 that collection is OWNED and the thin-client key carries\n// cms:write, so a visitor can create a pass of their own or PATCH its\n// expires_at (the owner stamp proves who wrote a row, not that anyone\n// paid; Lane-A write hooks see no principal, so no config rule can tell\n// that write from grant-pass's). The pass row is then (re)written as the\n// display record, keyed on the same charge id \u2014 a grant-pass delivery\n// that failed is never re-delivered (guide 08, \"What is retried, and what\n// is not\");\n// 4. CLAIMS the row \u2014 PATCH `result_status: 'queued'` with `If-Match: <version>`\n// \u2014 BEFORE spending anything: two invocations that both got past the read\n// (a real double click) resolve at the platform, the loser's 409 becomes a\n// 202 `already_queued`, and only one generation is ever started or metered;\n// 5. starts the brief on the ai JOB lane (mode: 'job') with correlation_id =\n// the session row id, then records the handle on the row. A generate that\n// fails, or a handle that could not be recorded, marks the row `failed`\n// with the reason, so the retry stays one click \u2014 never a row stuck `queued`.\n// Every write after the claim carries `If-Match: <the claim's version>`:\n// it lands only while the row is still THIS claim. A 409 there means the\n// row moved on (a generate slower than CLAIM_STALE_MS let the next click\n// sweep and re-claim it) and this attempt steps aside without a write.\n// The 202 handle is returned to the browser as the function's raw response.\n//\n// Dead claims. A row `queued` with no handle for longer than CLAIM_STALE_MS is\n// a claim whose generate never recorded (both PATCHes failed); one `queued`\n// with a handle for longer than QUEUE_STALE_MS \u2014 past the job lane's 570 s\n// ceiling \u2014 is a generation whose settle never landed. Both are started again\n// (the row is the record of truth; the next click sweeps it).\n//\n// Errors answer `{ error: { code, message } }`: /v1/fn returns this body\n// verbatim, and the SDK raises it as a VxilError whose `.code` is that code\n// (`pass_required`, `intake_incomplete`, \u2026) \u2014 so the page can branch on it.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = HttpFunctionEnvelope<{ session_id?: string }>;\ninterface SessionData {\n status?: string; step?: number; answers?: Record<string, unknown>; generation_id?: string | null; result_status?: string;\n}\ninterface Item { data?: { version?: number; updated_at?: string; data?: SessionData } }\ninterface Charge { charge_id: string; amount_cents: number; amount_refunded: number; currency: string; status: string; created_at: string }\ninterface ChargeList { data?: { charges?: Charge[] } }\ninterface Handle { data?: { generation_id?: string; run_id?: string; status?: string; correlation_id?: string | null } }\n\nconst LAST_STEP = 4;\nconst PASS_DAYS = 365; // mirrored in grant-pass.ts\nconst DAY_MS = 86_400_000;\nconst CLAIM_STALE_MS = 60_000; // a claim with no handle after a minute is dead (the generate round-trip is seconds)\nconst QUEUE_STALE_MS = 10 * 60_000; // a queued handle after ten minutes is dead (the job lane caps a run at 570 s)\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const pay = env.scoped_jwts?.payments;\n const sessionId = env.payload?.session_id;\n if (!cms || !ai || !pay) return fail(403, 'missing_scopes', 'the function needs cms:read, cms:write, ai:write and payments:read (the ledger is the pass)');\n // 1. Per-visitor only. A server key can still read every row directly; it\n // has no business starting a brief on someone's behalf through this door.\n if (!env.end_user?.id) return fail(403, 'end_user_required', 'invoke this as the signed-in visitor (vx.asEndUser(token))');\n if (!sessionId) return fail(422, 'validation_failed', 'need { session_id }');\n const row = { base, cms, sessionId };\n\n // 2. Re-read the row (owner-scoped: someone else's id is a 404 here too).\n const res = await fetch(`${base}/v1/cms/items/funnel_sessions/${encodeURIComponent(sessionId)}`, { headers: H(cms) });\n if (res.status === 404) return fail(404, 'session_not_found', 'no funnel session with that id belongs to you');\n if (!res.ok) return fail(502, 'upstream_error', `session read ${res.status}`);\n const item = ((await res.json()) as Item).data ?? {};\n const session = item.data ?? {};\n const version = item.version;\n const updatedAt = Date.parse(item.updated_at ?? '');\n const ageMs = Number.isFinite(updatedAt) ? Date.now() - updatedAt : 0;\n if (session.result_status === 'queued') {\n const dead = session.generation_id ? ageMs > QUEUE_STALE_MS : ageMs > CLAIM_STALE_MS;\n // A double click: the brief is already running \u2014 hand back the same handle.\n if (!dead) return Response.json({ generation_id: session.generation_id ?? null, status: 'already_queued' }, { status: 202 });\n }\n // A finished intake starts its brief; a brief that FAILED (settle-brief or\n // this function wrote `error_hint`) or a DEAD claim/queue may be started\n // again \u2014 the row is already `completed`, so the claim below is\n // completed \u2192 completed, which the transition hook allows.\n const intakeDone = session.status === 'in_progress' && (session.step ?? 0) >= LAST_STEP;\n const retry = session.status === 'completed' && (session.result_status === 'failed' || session.result_status === 'queued');\n if (!intakeDone && !retry) {\n if (session.status === 'completed') return fail(409, 'already_settled', 'this session already has its brief');\n return fail(409, 'intake_incomplete', `step ${session.step ?? 0} of ${LAST_STEP} \u2014 finish the intake first`);\n }\n\n // 3. A PAID pass \u2014 the LEDGER decides, on every call. Never a `passes` row:\n // that collection is owned, so the visitor's own thin-client key can\n // create one or stretch its expires_at. The charge list is bound to the\n // verified principal (a user_id query param would be ignored) and no\n // key a browser holds can write it.\n const now = new Date().toISOString();\n const paid = await eligibleCharge(base, pay, Date.parse(now));\n if (paid.kind === 'error') return fail(502, 'upstream_error', paid.reason);\n if (paid.kind === 'none') return fail(402, 'pass_required', 'buy a pass first \u2014 POST /v1/payments/checkout-sessions');\n // The pass ROW is the record the page shows. A grant-pass delivery that\n // failed is never re-delivered, so it may be missing: write it for THIS\n // charge (grant-pass's key \u2014 a 409 means it is recorded already). Its\n // outcome is ignored: the ledger has decided, a record never gates.\n await recordPass(base, cms, env.end_user.id, paid.charge);\n\n // 4. CLAIM the row before spending anything. `If-Match: <version>` makes the\n // claim a compare-and-set at the platform: a concurrent invocation that\n // read the same version loses with 409 version_conflict and hands back\n // `already_queued` \u2014 one generation, metered once. `generation_id: null`\n // marks \"claimed, handle not recorded yet\" for settle-brief; `error_hint:\n // null` clears what a failed attempt left. The `session_transition` hook\n // allows in_progress \u2192 completed (and completed \u2192 completed on a retry).\n const claim = await patchSession(row, {\n status: 'completed',\n completed_at: retry ? undefined : now,\n generation_id: null,\n result_status: 'queued',\n error_hint: null,\n }, { ifVersion: version });\n if (claim.status === 409) return alreadyQueued();\n if (!claim.ok) return fail(502, 'upstream_error', `session claim ${claim.status}`);\n // The claim's own version (the PATCH answers the updated row) guards every\n // later write as If-Match. Should the answer ever lack it, the `if`\n // precondition names the claim state instead.\n const claimVersion = ((await claim.json().catch(() => ({}))) as Item).data?.version;\n const mine: Row = {\n ...row,\n guard: typeof claimVersion === 'number'\n ? { ifVersion: claimVersion }\n : { if: { result_status: 'queued', generation_id: null } },\n };\n\n // 5. The brief, on the JOB lane: 202 now, the provider call runs in a\n // background run (up to 570 s), and the settle function gets the\n // completed|failed event with THIS correlation_id next to generation_id.\n // A generate that does not start hands the row back as `failed` with the\n // reason \u2014 the claim must never outlive the attempt it was for.\n const gen = await fetch(`${base}/v1/ai/generate`, {\n method: 'POST',\n headers: H(ai),\n body: JSON.stringify({\n template: 'studio-brief', // the template declared in vxil.config.ts (ai.templates)\n input: session.answers ?? {}, // {{goal}} {{audience}} {{tone}} {{constraints}}\n mode: 'job',\n correlation_id: sessionId, // \u2264128 chars; echoed on the handle and on the events\n }),\n }).catch(() => new Response(null, { status: 599 }));\n if (gen.status === 402) {\n await markFailed(mine, 'the ai budget for this user is spent');\n return fail(402, 'insufficient_credits', 'the ai budget for this user is spent');\n }\n if (gen.status !== 202 && !gen.ok) {\n await markFailed(mine, `generate ${gen.status}`);\n return fail(502, 'upstream_error', `generate ${gen.status}`);\n }\n const handle = ((await gen.json().catch(() => ({}))) as Handle).data ?? {};\n if (!handle.generation_id) {\n await markFailed(mine, 'generate returned no handle');\n return fail(502, 'upstream_error', 'generate returned no handle');\n }\n\n // 6. Record the handle. settle-brief matches the event's generation_id\n // against this (and re-reads a claimed row a few times while this PATCH\n // is in flight). If it cannot be recorded the row goes `failed` with the\n // reason: that generation's settle is skipped as stale and the visitor's\n // retry is one click \u2014 a second generation, never a row stuck `queued`.\n // A 409 is not a failure to record: the row is another claim's now, so\n // nothing is written and the visitor is pointed at the row's own brief.\n const rec = await patchSession(mine, { generation_id: handle.generation_id });\n if (rec.status === 409) return alreadyQueued();\n if (!rec.ok) {\n await markFailed(mine, `the brief started but its handle could not be recorded (patch ${rec.status}) \u2014 start again`);\n return fail(502, 'upstream_error', `session patch ${rec.status}`);\n }\n\n return Response.json({\n generation_id: handle.generation_id,\n run_id: handle.run_id,\n correlation_id: handle.correlation_id ?? sessionId,\n }, { status: 202 });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\n/** What a write is conditional on: `ifVersion` rides as If-Match (409\n * version_conflict when stale), `if` as the body precondition (409\n * precondition_failed). Every PATCH after the claim carries one. */\ninterface Guard { ifVersion?: number | undefined; if?: Record<string, unknown> }\ninterface Row { base: string; cms: string; sessionId: string; guard?: Guard }\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst fail = (status: number, code: string, message: string) =>\n Response.json({ error: { code, message } }, { status });\n/** A brief is already running for this row (the page watches the row for it). */\nconst alreadyQueued = () => Response.json({ generation_id: null, status: 'already_queued' }, { status: 202 });\n/** Owner-scoped PATCH, conditional on `guard` (the argument, else the row's own). */\nfunction patchSession(row: Row, data: Record<string, unknown>, guard: Guard | undefined = row.guard): Promise<Response> {\n return fetch(`${row.base}/v1/cms/items/funnel_sessions/${encodeURIComponent(row.sessionId)}`, {\n method: 'PATCH',\n headers: { ...H(row.cms), ...(guard?.ifVersion !== undefined ? { 'if-match': String(guard.ifVersion) } : {}) },\n body: JSON.stringify(guard?.if ? { data, if: guard.if } : { data }),\n }).catch(() => new Response(null, { status: 599 }));\n}\n/** Best-effort: hand a claimed row back as `failed` with the reason, so the\n * retry path (completed + failed) stays open. It rides the claim's guard, so\n * a 409 means the row moved on to a newer claim \u2014 left alone, silently. If\n * the mark fails otherwise, the dead-claim window above sweeps the row on the\n * next click. */\nasync function markFailed(row: Row, reason: string): Promise<void> {\n await patchSession(row, { result_status: 'failed', error_hint: reason.slice(0, 200) });\n}\n/** THE GATE: the newest charge on the OWNER-BOUND ledger that still earns a\n * pass \u2014 succeeded, never refunded (a refund, even a partial one, moves the\n * ledger status off `succeeded`), bought less than PASS_DAYS ago. One\n * product here, so any such charge is a pass purchase; if you sell more than\n * one thing, match the charge's amount (or your own product record) here too. */\nasync function eligibleCharge(\n base: string, pay: string, nowMs: number,\n): Promise<{ kind: 'paid'; charge: Charge } | { kind: 'none' } | { kind: 'error'; reason: string }> {\n const led = await fetch(`${base}/v1/payments/charges?status=succeeded&limit=50`, { headers: H(pay) })\n .catch(() => new Response(null, { status: 599 }));\n if (!led.ok) return { kind: 'error', reason: `ledger read ${led.status}` };\n const charges = ((await led.json().catch(() => ({}))) as ChargeList).data?.charges ?? [];\n const charge = charges.find((c) => c.status === 'succeeded' && !(c.amount_refunded > 0)\n && Date.parse(c.created_at) + PASS_DAYS * DAY_MS > nowMs); // an unparseable date is NaN \u2192 not eligible\n return charge ? { kind: 'paid', charge } : { kind: 'none' };\n}\n/** The display record for a charge the ledger already accepted, keyed on the\n * ledger charge id \u2014 the same key grant-pass writes, so a 409 means it is\n * there already. Best-effort by design: it is a record, never the pass. */\nasync function recordPass(base: string, cms: string, userId: string, c: Charge): Promise<void> {\n const granted = Date.parse(c.created_at);\n await fetch(`${base}/v1/cms/items/passes`, {\n method: 'POST',\n headers: H(cms),\n body: JSON.stringify({\n data: {\n user: userId, // end-user mode: the platform stamps the owner; naming yourself is allowed, anyone else is a 400\n charge_id: c.charge_id,\n product: 'studio-pass',\n amount_cents: c.amount_cents,\n granted_at: new Date(granted).toISOString(),\n expires_at: new Date(granted + PASS_DAYS * DAY_MS).toISOString(),\n },\n }),\n }).catch(() => undefined);\n}\n"
17971
+ "grant-pass.ts": "// grant-pass.ts \u2014 GRANT A PASS ON A SUCCESSFUL CHARGE (a vxil function).\n//\n// Trigger: webhook, source 'payments.charge.succeeded' \u2014 the FULL event name, so\n// this function wakes for exactly one event: the moment your provider's webhook\n// (Stripe, Paddle, PayPal, RevenueCat, or the mock) folds a succeeded charge.\n// The platform wires the subscription for you on deploy; the first attempt is\n// delivered directly, a few seconds after the event.\n//\n// The envelope's `payload` is the event, flattened:\n// { event, audit_id, occurred_at, actor, surface, data }\n// and `data` is the payments.charge.succeeded payload:\n// { end_user_id, provider, provider_charge_id, amount_cents, currency, product_id, environment }\n//\n// THREE RULES this function lives by:\n// \u2022 The event NAME is a routing claim, not an attestation \u2014 anyone who can\n// enqueue a job in your own project can produce a delivery with a chosen\n// name. So the ledger is re-read (GET /v1/payments/charges) before a pass\n// is written; a charge the ledger does not know is skipped.\n// \u2022 Delivery is at-least-once. `passes.charge_id` is UNIQUE (the LEDGER's\n// charge id \u2014 the same key start-brief's record write uses), so a redelivery\n// is a clean 409 unique_violation treated as \"already granted\".\n// \u2022 Nothing this function ANSWERS is re-delivered. The dispatcher ACKs every\n// platform-delivered trigger with a 200 whatever the handler returned\n// (guide 08, \"What is retried, and what is not\") \u2014 so a 5xx here is not a\n// retry request, it is the OBSERVATION channel: `functions.run.failed`\n// (once per failure streak) \u2192 the `webhooks.alerts` digest. Recovery is\n// data, not delivery: the pass row is a DISPLAY RECORD (an owned row the\n// visitor's own key could write, so it is never the entitlement) \u2014\n// start-brief decides from the same owner-bound ledger on every request,\n// so a grant lost here never blocks a payer, and it re-writes the missing\n// record. A decision (skip) answers 200 and is a decision.\n\nimport type { WebhookFunctionEnvelope } from '@vxil/sdk';\n\n/** the `payments.charge.succeeded` event payload (the fields read here) */\ninterface ChargeSucceeded {\n end_user_id?: string | null;\n provider_charge_id?: string | null;\n amount_cents?: number | null;\n currency?: string | null;\n product_id?: string | null;\n}\ntype Env = WebhookFunctionEnvelope<ChargeSucceeded>;\ninterface Charge { charge_id: string; amount_cents: number; currency: string; status: string }\ninterface ChargeList { data?: { charges?: Charge[] } }\ninterface Created { data?: { item_id?: string } }\ninterface ErrBody { error?: { code?: string } }\n\nconst PASS_DAYS = 365; // mirrored in start-brief.ts (the ledger gate)\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const pay = env.scoped_jwts?.payments;\n const cms = env.scoped_jwts?.cms;\n if (!pay || !cms) return fail(403, 'missing_scopes', 'the function needs payments:read and cms:write');\n // The binding's `source` already filters deliveries; this stays as belt-and-braces.\n if (env.payload?.event !== 'payments.charge.succeeded') {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n const raw = env.payload.data;\n // Past 64 KiB the whole `data` value is replaced by { truncated: true } \u2014\n // never a half object. A charge payload is tiny, but the check is free.\n if (raw && 'truncated' in raw) return Response.json({ skipped: true, reason: 'payload truncated' });\n const data: ChargeSucceeded = raw ?? {};\n const userId = data.end_user_id ?? null;\n const providerChargeId = data.provider_charge_id ?? null;\n if (!userId || !providerChargeId || data.amount_cents == null) {\n return Response.json({ skipped: true, reason: 'charge carries no user, id or amount' });\n }\n\n // 1. Re-read the LEDGER (the authoritative record) \u2014 a succeeded charge of\n // this amount must exist for this user. This is what turns the event\n // name from a claim into a fact. (One product, one pass per purchase:\n // the match is by amount; the list is newest-first.)\n const led = await fetch(\n `${base}/v1/payments/charges?user_id=${encodeURIComponent(userId)}&status=succeeded&limit=50`,\n { headers: H(pay) },\n ).catch(() => new Response(null, { status: 599 }));\n if (led.status >= 500) return unsettled(`ledger read ${led.status}`);\n if (!led.ok) return Response.json({ skipped: true, reason: `ledger read ${led.status}` });\n const charges = ((await led.json()) as ChargeList).data?.charges ?? [];\n const match = charges.find((c) => c.status === 'succeeded' && c.amount_cents === data.amount_cents\n && (!data.currency || c.currency === data.currency));\n if (!match) return Response.json({ skipped: true, reason: 'no succeeded charge of that amount on the ledger' });\n\n // 2. Write the pass RECORD. Server mode, so `user` is set explicitly (in\n // end-user mode the platform would stamp it). The ledger's `charge_id` is\n // the unique anchor \u2014 start-brief's record write keys on the same id.\n const now = Date.now();\n const created = await fetch(`${base}/v1/cms/items/passes`, {\n method: 'POST',\n headers: H(cms),\n body: JSON.stringify({\n data: {\n user: userId,\n charge_id: match.charge_id,\n product: data.product_id ?? 'studio-pass',\n amount_cents: data.amount_cents,\n granted_at: new Date(now).toISOString(),\n expires_at: new Date(now + PASS_DAYS * 86_400_000).toISOString(),\n },\n }),\n }).catch(() => new Response(null, { status: 599 }));\n if (created.status === 409) {\n const code = ((await created.json().catch(() => ({}))) as ErrBody).error?.code;\n // the redelivery case \u2014 the pass is already there\n return Response.json({ granted: false, reason: code ?? 'already granted', charge_id: match.charge_id });\n }\n if (created.status >= 500) return unsettled(`pass write ${created.status}`);\n if (!created.ok) return Response.json({ skipped: true, reason: `pass write ${created.status}` });\n const itemId = ((await created.json()) as Created).data?.item_id;\n return Response.json({\n granted: true, pass: itemId, user: userId, charge_id: match.charge_id, provider_charge_id: providerChargeId,\n });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst fail = (status: number, code: string, message: string) =>\n Response.json({ error: { code, message } }, { status });\n/** The charge succeeded and no pass was written. Answer 500 so the run is SEEN\n * (`functions.run.failed` \u2192 the alert digest); it is ACKed, never re-delivered\n * \u2014 start-brief decides from the ledger (the payer is never blocked) and\n * re-writes the missing record on the next request. */\nconst unsettled = (reason: string) =>\n fail(500, 'grant_unsettled', `${reason} \u2014 no pass written; surfaced via functions.run.failed (not re-delivered), repaired by start-brief from the ledger`);\n",
17972
+ "settle-brief.ts": "// settle-brief.ts \u2014 SETTLE THE BRIEF ONTO ITS ROW (a vxil function).\n//\n// Trigger: webhook, source 'job.generation.' \u2014 the PREFIX, so this function\n// wakes for both `job.generation.completed` and `job.generation.failed`. The\n// envelope's `payload.data` is the event payload:\n// { run_id, generation_id, correlation_id, status, error_class, error_hint, state, level }\n//\n// `correlation_id` is the funnel_sessions row id that start-brief sent with\n// the generate call \u2014 the platform echoes it on the event next to\n// generation_id, so this function needs no run\u2192record table of its own.\n//\n// On COMPLETED: the settled text lives in the generation's replay buffer\n// (GET /v1/ai/generations/{id}/stream?since=0 \u2014 every recorded frame, plus\n// `done`); the token frames are joined and PATCHed onto the row, then the\n// visitor gets a \"ready\" message. On FAILED: `error_hint` carries the\n// provider's own status and message (`Upstream said: \u2026`) and `error_class`\n// the class \u2014 the row gets the hint, so the visitor (and your support inbox)\n// sees WHY, not just that it failed. On completed both are null by contract.\n//\n// THE DELIVERY CONTRACT (guide 08, \"What is retried, and what is not\"): on\n// every platform-delivered trigger the dispatcher ACKs the delivery with a 200\n// WHATEVER this handler returns. A 5xx from here is never re-delivered \u2014 it is\n// OBSERVED (`functions.run.failed`, once per failure streak, digested by\n// `webhooks.alerts`). Only a crash of the dispatch itself is retried. So this\n// function never answers a 5xx hoping for a redelivery; it makes every failure\n// RECOVERABLE THROUGH THE ROW instead:\n// \u2022 a settle that cannot complete marks the row `result_status: 'failed'`\n// with `error_hint: 'settle failed: <reason>'` \u2014 start-brief accepts that\n// row again, so the visitor's retry is one click;\n// \u2022 only when not even that mark could be written does it answer 500 \u2014 not\n// for a retry (there is none) but so the failure is SEEN in the alert\n// digest instead of vanishing behind a 200.\n// The one transient it waits out ITSELF is the replay buffer: `done: false`\n// means the terminal frame is a moment behind the event, so the read is\n// repeated a few times before giving up (the guide's \"retry internally\").\n//\n// Every write carries `if: { generation_id }` \u2014 the row is touched only while\n// it is still waiting on THIS generation, so a late delivery for an earlier\n// attempt can never overwrite a newer one (409 precondition_failed = stale).\n// At-least-once: the same PATCH twice is the same row, and the \"ready\" send\n// carries the delivery's idempotency key, so a redelivery never double-mails.\n\nimport type { JobGenerationSettledEventPayload, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = WebhookFunctionEnvelope<JobGenerationSettledEventPayload>;\ninterface SessionData { user?: string; generation_id?: string | null; result_status?: string }\ninterface Item { data?: { data?: SessionData } }\ninterface Replay { data?: { frames?: Array<{ type?: string; delta?: string; done?: boolean }>; done?: boolean } }\n\nconst REPLAY_TRIES = 4; // reads of the replay buffer before giving up \u2026\nconst REPLAY_WAIT_MS = 500; // \u2026 and the pause between them (wall time, not CPU)\nconst ROW_TRIES = 3; // re-reads of a row still inside start-brief's claim\u2192generate\u2192record window\nconst ROW_WAIT_MS = 700;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const notif = env.scoped_jwts?.notifications;\n if (!cms || !ai) return fail(403, 'missing_scopes', 'the function needs cms:read, cms:write and ai:read');\n\n const event = env.payload?.event ?? '';\n if (event !== 'job.generation.completed' && event !== 'job.generation.failed') {\n return Response.json({ skipped: true, event }); // e.g. job.generation.processing\n }\n // a truncated payload ({ truncated: true }) carries nothing to settle from\n const raw = env.payload?.data;\n const data: Partial<JobGenerationSettledEventPayload> = raw && !('truncated' in raw) ? raw : {};\n const sessionId = data.correlation_id ?? null;\n const generationId = data.generation_id ?? null;\n if (!sessionId || !generationId) {\n return Response.json({ skipped: true, reason: 'not a funnel generation (no correlation_id)' });\n }\n const row = { base, cms, sessionId, generationId };\n\n // Find our row through the correlation id (server mode: any row). It must\n // be waiting on THIS generation: another id is a stale delivery (an earlier\n // attempt settling late) \u2014 unless the row is `queued` with no handle yet,\n // which is start-brief's claim\u2192generate\u2192record window: the handle is one\n // PATCH away, so the read is repeated before the delivery is called stale.\n let session: SessionData | null = null;\n let readStatus = 0;\n for (let i = 0; i < ROW_TRIES; i++) {\n if (i > 0) await sleep(ROW_WAIT_MS);\n const res = await fetch(`${base}/v1/cms/items/funnel_sessions/${encodeURIComponent(sessionId)}`, { headers: H(cms) });\n readStatus = res.status;\n if (res.status === 404) return Response.json({ skipped: true, reason: 'no such session' });\n if (!res.ok) continue; // a 5xx read: try again, then fall through to the blind mark below\n const s = ((await res.json()) as Item).data?.data ?? {};\n if (s.generation_id === generationId) { session = s; break; }\n if (s.result_status === 'queued' && !s.generation_id) continue; // claimed, handle not recorded yet\n return Response.json({ skipped: true, reason: 'stale generation', expected: s.generation_id ?? null });\n }\n if (!session) {\n if (readStatus >= 500) {\n // The row could not be read. Mark it failed anyway \u2014 the `if`\n // precondition lands the mark only if the row is still waiting on this\n // generation \u2014 and let the visitor retry.\n return await settleFailed(row, `session read ${readStatus}`);\n }\n return Response.json({ skipped: true, reason: 'stale generation (no handle recorded for it)' });\n }\n\n if (event === 'job.generation.failed') {\n // The provider's own words, bounded (the event caps the hint at 200 chars).\n const hint = data.error_hint ?? data.error_class ?? 'generation failed';\n const patch = await patchSession(row, { result_status: 'failed', error_hint: hint });\n if (patch.status === 409) return Response.json({ skipped: true, reason: 'stale generation' });\n if (!patch.ok) return unsettled(`session patch ${patch.status}`);\n return Response.json({ settled: 'failed', session: sessionId, error_class: data.error_class ?? null, error_hint: hint });\n }\n\n // COMPLETED \u2014 the text is in the replay buffer. `done: false` means the\n // buffer has not received its terminal frame yet \u2014 a moment behind the\n // event \u2014 so the read is repeated a few times before the brief is given\n // up (never settled empty; never a 5xx-and-hope: nothing re-delivers).\n let replay: NonNullable<Replay['data']> | null = null;\n let replayStatus = 0;\n for (let i = 0; i < REPLAY_TRIES; i++) {\n if (i > 0) await sleep(REPLAY_WAIT_MS);\n const rep = await fetch(`${base}/v1/ai/generations/${encodeURIComponent(generationId)}/stream?since=0`, { headers: H(ai) });\n replayStatus = rep.status;\n if (rep.status >= 400 && rep.status < 500) break; // not transient \u2014 a 404 generation will not appear\n if (!rep.ok) continue;\n const d = ((await rep.json()) as Replay).data ?? {};\n if (d.done) { replay = d; break; }\n }\n if (!replay) {\n return await settleFailed(row, replayStatus >= 400 ? `replay read ${replayStatus}` : 'replay buffer never reported done');\n }\n const text = (replay.frames ?? []).filter((f) => f.type === 'token').map((f) => f.delta ?? '').join('');\n\n const patch = await patchSession(row, { result_status: 'completed', result: text, error_hint: null });\n if (patch.status === 409) return Response.json({ skipped: true, reason: 'stale generation' });\n if (!patch.ok) return await settleFailed(row, `session patch ${patch.status}`);\n\n // Tell the visitor. Best-effort AFTER the result is on the row: a mail that\n // fails must not un-settle a brief that is ready. The delivery's\n // idempotency key rides the send, so a redelivered event never sends twice\n // (notifications honors Idempotency-Key).\n let delivery: number | null = null;\n if (notif && session.user) {\n const send = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { ...H(notif), ...(env.idempotency_key ? { 'idempotency-key': env.idempotency_key } : {}) },\n body: JSON.stringify({\n user_id: session.user,\n template: 'transactional',\n data: { subject: 'Your brief is ready', paragraph: 'Open the studio to read your creative brief.' },\n }),\n }).catch(() => null);\n delivery = send?.status ?? null;\n }\n return Response.json({ settled: 'completed', session: sessionId, chars: text.length, delivery });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\ninterface Row { base: string; cms: string; sessionId: string; generationId: string }\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));\nconst fail = (status: number, code: string, message: string) =>\n Response.json({ error: { code, message } }, { status });\n/** Not even the row could record the failure: answer 500 so the run is SEEN\n * (`functions.run.failed` \u2192 the alert digest). ACKed, never re-delivered. */\nconst unsettled = (reason: string) =>\n fail(500, 'unsettled', `${reason} \u2014 the row could not be marked failed; surfaced via functions.run.failed, not re-delivered`);\n/** Every write is guarded by `if: { generation_id }`: the row is touched only\n * while it still waits on THIS generation (409 precondition_failed = stale). */\nfunction patchSession(row: Row, data: Record<string, unknown>): Promise<Response> {\n return fetch(`${row.base}/v1/cms/items/funnel_sessions/${encodeURIComponent(row.sessionId)}`, {\n method: 'PATCH', headers: H(row.cms), body: JSON.stringify({ data, if: { generation_id: row.generationId } }),\n }).catch(() => new Response(null, { status: 599 }));\n}\n/** The recoverable outcome: mark the row failed with the reason, so start-brief\n * accepts it again and the visitor's retry is one click. */\nasync function settleFailed(row: Row, reason: string): Promise<Response> {\n const patch = await patchSession(row, { result_status: 'failed', error_hint: `settle failed: ${reason}`.slice(0, 200) });\n if (patch.ok) return Response.json({ settled: 'failed', session: row.sessionId, reason });\n if (patch.status === 409) return Response.json({ skipped: true, reason: 'stale generation' });\n return unsettled(`${reason}; mark failed \u2192 ${patch.status}`);\n}\n",
17973
+ "start-brief.ts": "// start-brief.ts \u2014 START THE AI BRIEF (a vxil function).\n//\n// Trigger: http \u2014 POST /v1/fn/start-brief, called by the browser AS THE\n// SIGNED-IN VISITOR (vx.asEndUser(session.token).fn['start-brief']({ session_id })).\n// The verified end-user principal rides into this function: `env.end_user` names\n// them, and every scoped token below carries the same principal, so the cms\n// reads are OWNER-SCOPED (a foreign session id is an honest 404), the charge\n// list is bound to them, and the generation is owned by \u2014 and metered to \u2014\n// that user, never a body field.\n//\n// What it does, in order:\n// 1. refuses a server-mode call (the funnel is a per-visitor flow);\n// 2. re-reads the session row (with its `version`) and requires a finished\n// intake \u2014 or a brief that FAILED, or a claim/queue that DIED (below);\n// 3. requires a PAID pass, decided by the owner-bound LEDGER on every call:\n// a succeeded, unrefunded charge from the last PASS_DAYS. Never by a\n// `passes` row \u2014 that collection is OWNED and the thin-client key carries\n// cms:write, so a visitor can create a pass of their own or PATCH its\n// expires_at (the owner stamp proves who wrote a row, not that anyone\n// paid; Lane-A write hooks see no principal, so no config rule can tell\n// that write from grant-pass's). The pass row is then (re)written as the\n// display record, keyed on the same charge id \u2014 a grant-pass delivery\n// that failed is never re-delivered (guide 08, \"What is retried, and what\n// is not\");\n// 4. CLAIMS the row \u2014 PATCH `result_status: 'queued'` with `If-Match: <version>`\n// \u2014 BEFORE spending anything: two invocations that both got past the read\n// (a real double click) resolve at the platform, the loser's 409 becomes a\n// 202 `already_queued`, and only one generation is ever started or metered;\n// 5. starts the brief on the ai JOB lane (mode: 'job') with correlation_id =\n// the session row id, then records the handle on the row. A generate that\n// fails, or a handle that could not be recorded, marks the row `failed`\n// with the reason, so the retry stays one click \u2014 never a row stuck `queued`.\n// Every write after the claim carries `If-Match: <the claim's version>`:\n// it lands only while the row is still THIS claim. A 409 there means the\n// row moved on (a generate slower than CLAIM_STALE_MS let the next click\n// sweep and re-claim it) and this attempt steps aside without a write.\n// The 202 handle is returned to the browser as the function's raw response.\n//\n// Dead claims. A row `queued` with no handle for longer than CLAIM_STALE_MS is\n// a claim whose generate never recorded (both PATCHes failed); one `queued`\n// with a handle for longer than QUEUE_STALE_MS \u2014 past the job lane's 570 s\n// ceiling \u2014 is a generation whose settle never landed. Both are started again\n// (the row is the record of truth; the next click sweeps it).\n//\n// Errors answer `{ error: { code, message } }`: /v1/fn returns this body\n// verbatim, and the SDK raises it as a VxilError whose `.code` is that code\n// (`pass_required`, `intake_incomplete`, \u2026) \u2014 so the page can branch on it.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = HttpFunctionEnvelope<{ session_id?: string }>;\ninterface SessionData {\n status?: string; step?: number; answers?: Record<string, unknown>; generation_id?: string | null; result_status?: string;\n}\ninterface Item { data?: { version?: number; updated_at?: string; data?: SessionData } }\ninterface Charge { charge_id: string; amount_cents: number; amount_refunded: number; currency: string; status: string; created_at: string }\ninterface ChargeList { data?: { charges?: Charge[] } }\ninterface Handle { data?: { generation_id?: string; run_id?: string; status?: string; correlation_id?: string | null } }\n\nconst LAST_STEP = 4;\nconst PASS_DAYS = 365; // mirrored in grant-pass.ts\nconst DAY_MS = 86_400_000;\nconst CLAIM_STALE_MS = 60_000; // a claim with no handle after a minute is dead (the generate round-trip is seconds)\nconst QUEUE_STALE_MS = 10 * 60_000; // a queued handle after ten minutes is dead (the job lane caps a run at 570 s)\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const pay = env.scoped_jwts?.payments;\n const sessionId = env.payload?.session_id;\n if (!cms || !ai || !pay) return fail(403, 'missing_scopes', 'the function needs cms:read, cms:write, ai:write and payments:read (the ledger is the pass)');\n // 1. Per-visitor only. A server key can still read every row directly; it\n // has no business starting a brief on someone's behalf through this door.\n if (!env.end_user?.id) return fail(403, 'end_user_required', 'invoke this as the signed-in visitor (vx.asEndUser(token))');\n if (!sessionId) return fail(422, 'validation_failed', 'need { session_id }');\n const row = { base, cms, sessionId };\n\n // 2. Re-read the row (owner-scoped: someone else's id is a 404 here too).\n const res = await fetch(`${base}/v1/cms/items/funnel_sessions/${encodeURIComponent(sessionId)}`, { headers: H(cms) });\n if (res.status === 404) return fail(404, 'session_not_found', 'no funnel session with that id belongs to you');\n if (!res.ok) return fail(502, 'upstream_error', `session read ${res.status}`);\n const item = ((await res.json()) as Item).data ?? {};\n const session = item.data ?? {};\n const version = item.version;\n const updatedAt = Date.parse(item.updated_at ?? '');\n const ageMs = Number.isFinite(updatedAt) ? Date.now() - updatedAt : 0;\n if (session.result_status === 'queued') {\n const dead = session.generation_id ? ageMs > QUEUE_STALE_MS : ageMs > CLAIM_STALE_MS;\n // A double click: the brief is already running \u2014 hand back the same handle.\n if (!dead) return Response.json({ generation_id: session.generation_id ?? null, status: 'already_queued' }, { status: 202 });\n }\n // A finished intake starts its brief; a brief that FAILED (settle-brief or\n // this function wrote `error_hint`) or a DEAD claim/queue may be started\n // again \u2014 the row is already `completed`, so the claim below is\n // completed \u2192 completed, which the transition hook allows.\n const intakeDone = session.status === 'in_progress' && (session.step ?? 0) >= LAST_STEP;\n const retry = session.status === 'completed' && (session.result_status === 'failed' || session.result_status === 'queued');\n if (!intakeDone && !retry) {\n if (session.status === 'completed') return fail(409, 'already_settled', 'this session already has its brief');\n return fail(409, 'intake_incomplete', `step ${session.step ?? 0} of ${LAST_STEP} \u2014 finish the intake first`);\n }\n\n // 3. A PAID pass \u2014 the LEDGER decides, on every call. Never a `passes` row:\n // that collection is owned, so the visitor's own thin-client key can\n // create one or stretch its expires_at. The charge list is bound to the\n // verified principal (a user_id query param would be ignored) and no\n // key a browser holds can write it.\n const now = new Date().toISOString();\n const paid = await eligibleCharge(base, pay, Date.parse(now));\n if (paid.kind === 'error') return fail(502, 'upstream_error', paid.reason);\n if (paid.kind === 'none') return fail(402, 'pass_required', 'buy a pass first \u2014 POST /v1/payments/checkout-sessions');\n // The pass ROW is the record the page shows. A grant-pass delivery that\n // failed is never re-delivered, so it may be missing: write it for THIS\n // charge (grant-pass's key \u2014 a 409 means it is recorded already). Its\n // outcome is ignored: the ledger has decided, a record never gates.\n await recordPass(base, cms, env.end_user.id, paid.charge);\n\n // 4. CLAIM the row before spending anything. `If-Match: <version>` makes the\n // claim a compare-and-set at the platform: a concurrent invocation that\n // read the same version loses with 409 version_conflict and hands back\n // `already_queued` \u2014 one generation, metered once. `generation_id: null`\n // marks \"claimed, handle not recorded yet\" for settle-brief; `error_hint:\n // null` clears what a failed attempt left. The `session_transition` hook\n // allows in_progress \u2192 completed (and completed \u2192 completed on a retry).\n const claim = await patchSession(row, {\n status: 'completed',\n completed_at: retry ? undefined : now,\n generation_id: null,\n result_status: 'queued',\n error_hint: null,\n }, { ifVersion: version });\n if (claim.status === 409) return alreadyQueued();\n if (!claim.ok) return fail(502, 'upstream_error', `session claim ${claim.status}`);\n // The claim's own version (the PATCH answers the updated row) guards every\n // later write as If-Match. Should the answer ever lack it, the `if`\n // precondition names the claim state instead.\n const claimVersion = ((await claim.json().catch(() => ({}))) as Item).data?.version;\n const mine: Row = {\n ...row,\n guard: typeof claimVersion === 'number'\n ? { ifVersion: claimVersion }\n : { if: { result_status: 'queued', generation_id: null } },\n };\n\n // 5. The brief, on the JOB lane: 202 now, the provider call runs in a\n // background run (up to 570 s), and the settle function gets the\n // completed|failed event with THIS correlation_id next to generation_id.\n // A generate that does not start hands the row back as `failed` with the\n // reason \u2014 the claim must never outlive the attempt it was for.\n const gen = await fetch(`${base}/v1/ai/generate`, {\n method: 'POST',\n headers: H(ai),\n body: JSON.stringify({\n template: 'studio-brief', // the template declared in vxil.config.ts (ai.templates)\n input: session.answers ?? {}, // {{goal}} {{audience}} {{tone}} {{constraints}}\n mode: 'job',\n correlation_id: sessionId, // \u2264128 chars; echoed on the handle and on the events\n }),\n }).catch(() => new Response(null, { status: 599 }));\n if (gen.status === 402) {\n await markFailed(mine, 'the ai budget for this user is spent');\n return fail(402, 'insufficient_credits', 'the ai budget for this user is spent');\n }\n if (gen.status !== 202 && !gen.ok) {\n await markFailed(mine, `generate ${gen.status}`);\n return fail(502, 'upstream_error', `generate ${gen.status}`);\n }\n const handle = ((await gen.json().catch(() => ({}))) as Handle).data ?? {};\n if (!handle.generation_id) {\n await markFailed(mine, 'generate returned no handle');\n return fail(502, 'upstream_error', 'generate returned no handle');\n }\n\n // 6. Record the handle. settle-brief matches the event's generation_id\n // against this (and re-reads a claimed row a few times while this PATCH\n // is in flight). If it cannot be recorded the row goes `failed` with the\n // reason: that generation's settle is skipped as stale and the visitor's\n // retry is one click \u2014 a second generation, never a row stuck `queued`.\n // A 409 is not a failure to record: the row is another claim's now, so\n // nothing is written and the visitor is pointed at the row's own brief.\n const rec = await patchSession(mine, { generation_id: handle.generation_id });\n if (rec.status === 409) return alreadyQueued();\n if (!rec.ok) {\n await markFailed(mine, `the brief started but its handle could not be recorded (patch ${rec.status}) \u2014 start again`);\n return fail(502, 'upstream_error', `session patch ${rec.status}`);\n }\n\n return Response.json({\n generation_id: handle.generation_id,\n run_id: handle.run_id,\n correlation_id: handle.correlation_id ?? sessionId,\n }, { status: 202 });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\n/** What a write is conditional on: `ifVersion` rides as If-Match (409\n * version_conflict when stale), `if` as the body precondition (409\n * precondition_failed). Every PATCH after the claim carries one. */\ninterface Guard { ifVersion?: number | undefined; if?: Record<string, unknown> }\ninterface Row { base: string; cms: string; sessionId: string; guard?: Guard }\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst fail = (status: number, code: string, message: string) =>\n Response.json({ error: { code, message } }, { status });\n/** A brief is already running for this row (the page watches the row for it). */\nconst alreadyQueued = () => Response.json({ generation_id: null, status: 'already_queued' }, { status: 202 });\n/** Owner-scoped PATCH, conditional on `guard` (the argument, else the row's own). */\nfunction patchSession(row: Row, data: Record<string, unknown>, guard: Guard | undefined = row.guard): Promise<Response> {\n return fetch(`${row.base}/v1/cms/items/funnel_sessions/${encodeURIComponent(row.sessionId)}`, {\n method: 'PATCH',\n headers: { ...H(row.cms), ...(guard?.ifVersion !== undefined ? { 'if-match': String(guard.ifVersion) } : {}) },\n body: JSON.stringify(guard?.if ? { data, if: guard.if } : { data }),\n }).catch(() => new Response(null, { status: 599 }));\n}\n/** Best-effort: hand a claimed row back as `failed` with the reason, so the\n * retry path (completed + failed) stays open. It rides the claim's guard, so\n * a 409 means the row moved on to a newer claim \u2014 left alone, silently. If\n * the mark fails otherwise, the dead-claim window above sweeps the row on the\n * next click. */\nasync function markFailed(row: Row, reason: string): Promise<void> {\n await patchSession(row, { result_status: 'failed', error_hint: reason.slice(0, 200) });\n}\n/** THE GATE: the newest charge on the OWNER-BOUND ledger that still earns a\n * pass \u2014 succeeded, never refunded (a refund, even a partial one, moves the\n * ledger status off `succeeded`), bought less than PASS_DAYS ago. One\n * product here, so any such charge is a pass purchase; if you sell more than\n * one thing, match the charge's amount (or your own product record) here too. */\nasync function eligibleCharge(\n base: string, pay: string, nowMs: number,\n): Promise<{ kind: 'paid'; charge: Charge } | { kind: 'none' } | { kind: 'error'; reason: string }> {\n const led = await fetch(`${base}/v1/payments/charges?status=succeeded&limit=50`, { headers: H(pay) })\n .catch(() => new Response(null, { status: 599 }));\n if (!led.ok) return { kind: 'error', reason: `ledger read ${led.status}` };\n const charges = ((await led.json().catch(() => ({}))) as ChargeList).data?.charges ?? [];\n const charge = charges.find((c) => c.status === 'succeeded' && !(c.amount_refunded > 0)\n && Date.parse(c.created_at) + PASS_DAYS * DAY_MS > nowMs); // an unparseable date is NaN \u2192 not eligible\n return charge ? { kind: 'paid', charge } : { kind: 'none' };\n}\n/** The display record for a charge the ledger already accepted, keyed on the\n * ledger charge id \u2014 the same key grant-pass writes, so a 409 means it is\n * there already. Best-effort by design: it is a record, never the pass. */\nasync function recordPass(base: string, cms: string, userId: string, c: Charge): Promise<void> {\n const granted = Date.parse(c.created_at);\n await fetch(`${base}/v1/cms/items/passes`, {\n method: 'POST',\n headers: H(cms),\n body: JSON.stringify({\n data: {\n user: userId, // end-user mode: the platform stamps the owner; naming yourself is allowed, anyone else is a 400\n charge_id: c.charge_id,\n product: 'studio-pass',\n amount_cents: c.amount_cents,\n granted_at: new Date(granted).toISOString(),\n expires_at: new Date(granted + PASS_DAYS * DAY_MS).toISOString(),\n },\n }),\n }).catch(() => undefined);\n}\n"
18322
17974
  }
18323
17975
  },
18324
17976
  {
@@ -18336,8 +17988,8 @@ export default defineConfig({
18336
17988
  ],
18337
17989
  "hasFunctions": false,
18338
17990
  "byoKeys": [],
18339
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Leaderboard\" \u2014 game scores + live rankings, declared end-to-end in ONE typed\n// file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 players + scores. THE POINT: the leaderboards themselves are\n// never stored \u2014 `POST /v1/cms/items/scores/rank` and\n// `\u2026/aggregate` are bounded, config-free read-models over the\n// raw score rows (vxil.com/docs/api: the aggregate route), and\n// `?count=true` gives an exact \"entries ahead of me\" (\xA79.4).\n// \u2022 rate-limits \u2192 the abuse guard for PUBLIC score submission. Policies are\n// data on the feature's own REST surface (POST\n// /v1/rate-limits/policies + \u2026/check), not config leaves \u2014\n// see the README for the exact bodies.\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {}, // defaults suffice \u2014 rankings are ad-hoc bounded reads, not config\n 'rate-limits': {}, // enabled with defaults; the submission policy is REST data\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n players: {\n singular: 'player',\n fields: {\n handle: { type: 'string', required: true, indexSlot: 's1', unique: true },\n display_name: { type: 'string', indexSlot: 's2' },\n country: { type: 'string', indexSlot: 's3' },\n },\n },\n // One row per RUN. Per-player \"best\" is derived at read time by\n // rank/aggregate (`max(score)` grouped by `player`) \u2014 nothing to keep\n // consistent on write.\n scores: {\n singular: 'score',\n fields: {\n // The ranked entity: rank/aggregate group by this field's s1 slot,\n // i.e. by the STORED value (for real submissions, the player item_id).\n player: { type: 'relation', relationTo: 'players', indexSlot: 's1' },\n game_mode: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['classic', 'blitz'] } },\n // n1 slot \u21D2 max/avg aggregates + `$gt` range (\"players ahead of me\").\n score: { type: 'int', required: true, indexSlot: 'n1', validation: { min: 0 } },\n // t1 slot \u21D2 `window: { field: 'achieved_at', sinceDays: 7 }` = weekly boards.\n achieved_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n seed: {\n // `vxil seed` POSTs these verbatim, in order \u2014 it cannot capture the\n // item_ids the player rows are assigned. The demo scores therefore carry\n // the players' stable HANDLES as the relation value (type-valid: a\n // relation stores an opaque string, and grouping ranks by the stored\n // value either way). Real submissions should store the item_id that\n // `create` returns \u2014 that is what makes `$expand` and dotted join\n // filters (e.g. `player.country`) resolve.\n cms: [\n {\n collection: 'players',\n items: [\n { handle: 'nova', display_name: 'Nova', country: 'JO' },\n { handle: 'rex', display_name: 'Rex', country: 'DE' },\n { handle: 'zed', display_name: 'Zed', country: 'JP' },\n ],\n },\n {\n collection: 'scores',\n items: [\n { player: 'nova', game_mode: 'classic', score: 12500, achieved_at: '2026-07-01T10:00:00Z' },\n { player: 'zed', game_mode: 'classic', score: 11200, achieved_at: '2026-07-02T09:15:00Z' },\n { player: 'rex', game_mode: 'classic', score: 9800, achieved_at: '2026-07-03T11:30:00Z' },\n { player: 'nova', game_mode: 'blitz', score: 4200, achieved_at: '2026-07-03T18:00:00Z' },\n { player: 'rex', game_mode: 'blitz', score: 5100, achieved_at: '2026-07-04T20:45:00Z' },\n ],\n },\n ],\n },\n});\n",
18340
- "readme": '# Game Leaderboard template\n\nGame scores + live rankings in one typed `vxil.config.ts`. The leaderboards themselves are **never stored** \u2014\ntop-N, per-mode stats, and "players ahead of me" are all bounded reads over the raw score rows, so there are no\ncounters, crons, or denormalized tables to keep consistent.\n\n**Provisions:**\n- `players` \u2014 unique `handle`, `display_name`, `country`.\n- `scores` \u2014 `player` relation (the ranked entity), `game_mode` (enum), `score` (int, `min: 0`), `achieved_at`.\n One row per run; per-player "best" is derived at read time.\n- `rate-limits` \u2014 enabled as the abuse guard for public score submission (the policy is REST data, see below).\n\n**Apply it:**\n\n```bash\nvxil init --template leaderboard\nvxil quickstart # or: vxil link\nvxil push\nvxil seed # 3 players + 5 demo scores across 2 game modes\nvxil gen\n```\n\n**What to learn from this:**\n1. **Config-free leaderboards.** `POST /v1/cms/items/scores/rank` and `\u2026/aggregate` are bounded read-models\n (\u2264500 groups over a \u226450k-row scan; an over-wide scan fails `422 window_too_large` instead of silently\n truncating \u2014 vxil.com/docs/api, the aggregate route). `window: { field: \'achieved_at\', sinceDays: 7 }` = weekly boards.\n2. **`?count=true` is uncapped.** List pages clamp at the page-size limit, but a count is one\n `SELECT count(*)` under the same compiled filter (\xA79.4) \u2014 an exact "entries ahead of me" even at\n position 40,000. It counts score *rows*; for strictly "players ahead" keep one row per (player, mode)\n (patch on a new best), or read the rank endpoint (within its \xA712 bounds).\n3. **rate-limits as the abuse guard.** Public score submission is the classic spam target: create a named\n policy once, then check per player before accepting a run (vxil.com/docs/guide/06-feature-catalog: rate-limits).\n\n**Top-10 by best score per player in a mode** (`VXIL_BASE` = your edge, e.g. `https://api.vxil.com`):\n\n```bash\ncurl -sX POST "$VXIL_BASE/v1/cms/items/scores/rank" \\\n -H "Authorization: Bearer $VXIL_KEY" -H "content-type: application/json" \\\n -d \'{"groupBy":"player","metric":{"fn":"max","field":"score"},\n "rank":"rank","direction":"desc","filter":{"game_mode":"classic"},"limit":10}\'\n# \u2192 { "data": { "entries": [ { "key": { "player": "nova" }, "metric": 12500, "rank": 1 }, \u2026 ], "scanned": 3 } }\n```\n\n**Per-mode stats + a player\'s position** (typed SDK, after `vxil gen`):\n\n```ts\nconst { groups } = await vx.from(\'scores\').aggregate({\n aggregates: [{ fn: \'count\', as: \'runs\' }, { fn: \'max\', field: \'score\', as: \'top\' }],\n groupBy: [\'game_mode\'],\n});\nconst ahead = await vx.from(\'scores\').count({ game_mode: \'classic\', score: { $gt: 11200 } });\n// position = ahead + 1\n```\n\n**The submission guard** (once, from your server):\n\n```bash\ncurl -sX POST "$VXIL_BASE/v1/rate-limits/policies" \\\n -H "Authorization: Bearer $VXIL_KEY" -H "content-type: application/json" \\\n -d \'{"name":"scores.submit","key_template":"{player_id}","limit":10,"window_seconds":60,"behavior":"block"}\'\n# per run: POST /v1/rate-limits/check {"policy_id":"rl_\u2026","key_values":{"player_id":"<player item_id>"},"cost":1} \u2192 429 when exhausted\n```\n\n> The demo seed stores player *handles* as the `scores.player` relation value (a seed can\'t know generated\n> item_ids); real submissions should store the item_id `create` returns so `$expand`/dotted joins resolve.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7 vxil.com/docs/api (count, aggregate/rank) \xB7\nvxil.com/docs/guide/06-feature-catalog (rate-limits: policies, checks, per-identifier overrides) \xB7\n`examples/ecommerce/` (a bigger cms composition, with functions).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
17991
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Leaderboard\" \u2014 game scores + live rankings, declared end-to-end in ONE typed\n// file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 players + scores. THE POINT: the leaderboards themselves are\n// never stored \u2014 `POST /v1/cms/items/scores/rank` and\n// `\u2026/aggregate` are bounded, config-free read-models over the\n// raw score rows (vxil.com/docs/api: the aggregate route), and\n// `?count=true` gives an exact \"entries ahead of me\" (guide ch. 4, querying).\n// \u2022 rate-limits \u2192 the abuse guard for PUBLIC score submission. Policies are\n// data on the feature's own REST surface (POST\n// /v1/rate-limits/policies + \u2026/check), not config leaves \u2014\n// see the README for the exact bodies.\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {}, // defaults suffice \u2014 rankings are ad-hoc bounded reads, not config\n 'rate-limits': {}, // enabled with defaults; the submission policy is REST data\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n players: {\n singular: 'player',\n fields: {\n handle: { type: 'string', required: true, indexSlot: 's1', unique: true },\n display_name: { type: 'string', indexSlot: 's2' },\n country: { type: 'string', indexSlot: 's3' },\n },\n },\n // One row per RUN. Per-player \"best\" is derived at read time by\n // rank/aggregate (`max(score)` grouped by `player`) \u2014 nothing to keep\n // consistent on write.\n scores: {\n singular: 'score',\n fields: {\n // The ranked entity: rank/aggregate group by this field's s1 slot,\n // i.e. by the STORED value (for real submissions, the player item_id).\n player: { type: 'relation', relationTo: 'players', indexSlot: 's1' },\n game_mode: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['classic', 'blitz'] } },\n // n1 slot \u21D2 max/avg aggregates + `$gt` range (\"players ahead of me\").\n score: { type: 'int', required: true, indexSlot: 'n1', validation: { min: 0 } },\n // t1 slot \u21D2 `window: { field: 'achieved_at', sinceDays: 7 }` = weekly boards.\n achieved_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n seed: {\n // `vxil seed` POSTs these verbatim, in order \u2014 it cannot capture the\n // item_ids the player rows are assigned. The demo scores therefore carry\n // the players' stable HANDLES as the relation value (type-valid: a\n // relation stores an opaque string, and grouping ranks by the stored\n // value either way). Real submissions should store the item_id that\n // `create` returns \u2014 that is what makes `$expand` and dotted join\n // filters (e.g. `player.country`) resolve.\n cms: [\n {\n collection: 'players',\n items: [\n { handle: 'nova', display_name: 'Nova', country: 'JO' },\n { handle: 'rex', display_name: 'Rex', country: 'DE' },\n { handle: 'zed', display_name: 'Zed', country: 'JP' },\n ],\n },\n {\n collection: 'scores',\n items: [\n { player: 'nova', game_mode: 'classic', score: 12500, achieved_at: '2026-07-01T10:00:00Z' },\n { player: 'zed', game_mode: 'classic', score: 11200, achieved_at: '2026-07-02T09:15:00Z' },\n { player: 'rex', game_mode: 'classic', score: 9800, achieved_at: '2026-07-03T11:30:00Z' },\n { player: 'nova', game_mode: 'blitz', score: 4200, achieved_at: '2026-07-03T18:00:00Z' },\n { player: 'rex', game_mode: 'blitz', score: 5100, achieved_at: '2026-07-04T20:45:00Z' },\n ],\n },\n ],\n },\n});\n",
17992
+ "readme": '# Game Leaderboard template\n\nGame scores + live rankings in one typed `vxil.config.ts`. The leaderboards themselves are **never stored** \u2014\ntop-N, per-mode stats, and "players ahead of me" are all bounded reads over the raw score rows, so there are no\ncounters, crons, or denormalized tables to keep consistent.\n\n**Provisions:**\n- `players` \u2014 unique `handle`, `display_name`, `country`.\n- `scores` \u2014 `player` relation (the ranked entity), `game_mode` (enum), `score` (int, `min: 0`), `achieved_at`.\n One row per run; per-player "best" is derived at read time.\n- `rate-limits` \u2014 enabled as the abuse guard for public score submission (the policy is REST data, see below).\n\n**Apply it:**\n\n```bash\nvxil init --template leaderboard\nvxil quickstart # or: vxil link\nvxil push\nvxil seed # 3 players + 5 demo scores across 2 game modes\nvxil gen\n```\n\n**What to learn from this:**\n1. **Config-free leaderboards.** `POST /v1/cms/items/scores/rank` and `\u2026/aggregate` are bounded read-models\n (\u2264500 groups over a \u226450k-row scan; an over-wide scan fails `422 window_too_large` instead of silently\n truncating \u2014 vxil.com/docs/api, the aggregate route). `window: { field: \'achieved_at\', sinceDays: 7 }` = weekly boards.\n2. **`?count=true` is uncapped.** List pages clamp at the page-size limit, but a count is one\n `SELECT count(*)` under the same compiled filter (guide ch. 4, querying) \u2014 an exact "entries ahead of me" even at\n position 40,000. It counts score *rows*; for strictly "players ahead" keep one row per (player, mode)\n (patch on a new best), or read the rank endpoint (within its documented bounds).\n3. **rate-limits as the abuse guard.** Public score submission is the classic spam target: create a named\n policy once, then check per player before accepting a run (vxil.com/docs/guide/06-feature-catalog: rate-limits).\n\n**Top-10 by best score per player in a mode** (`VXIL_BASE` = your edge, e.g. `https://api.vxil.com`):\n\n```bash\ncurl -sX POST "$VXIL_BASE/v1/cms/items/scores/rank" \\\n -H "Authorization: Bearer $VXIL_KEY" -H "content-type: application/json" \\\n -d \'{"groupBy":"player","metric":{"fn":"max","field":"score"},\n "rank":"rank","direction":"desc","filter":{"game_mode":"classic"},"limit":10}\'\n# \u2192 { "data": { "entries": [ { "key": { "player": "nova" }, "metric": 12500, "rank": 1 }, \u2026 ], "scanned": 3 } }\n```\n\n**Per-mode stats + a player\'s position** (typed SDK, after `vxil gen`):\n\n```ts\nconst { groups } = await vx.from(\'scores\').aggregate({\n aggregates: [{ fn: \'count\', as: \'runs\' }, { fn: \'max\', field: \'score\', as: \'top\' }],\n groupBy: [\'game_mode\'],\n});\nconst ahead = await vx.from(\'scores\').count({ game_mode: \'classic\', score: { $gt: 11200 } });\n// position = ahead + 1\n```\n\n**The submission guard** (once, from your server):\n\n```bash\ncurl -sX POST "$VXIL_BASE/v1/rate-limits/policies" \\\n -H "Authorization: Bearer $VXIL_KEY" -H "content-type: application/json" \\\n -d \'{"name":"scores.submit","key_template":"{player_id}","limit":10,"window_seconds":60,"behavior":"block"}\'\n# per run: POST /v1/rate-limits/check {"policy_id":"rl_\u2026","key_values":{"player_id":"<player item_id>"},"cost":1} \u2192 429 when exhausted\n```\n\n> The demo seed stores player *handles* as the `scores.player` relation value (a seed can\'t know generated\n> item_ids); real submissions should store the item_id `create` returns so `$expand`/dotted joins resolve.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7 vxil.com/docs/api (count, aggregate/rank) \xB7\nvxil.com/docs/guide/06-feature-catalog (rate-limits: policies, checks, per-identifier overrides) \xB7\n`examples/ecommerce/` (a bigger cms composition, with functions).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
18341
17993
  "functions": {}
18342
17994
  },
18343
17995
  {
@@ -18357,11 +18009,11 @@ export default defineConfig({
18357
18009
  ],
18358
18010
  "hasFunctions": true,
18359
18011
  "byoKeys": [],
18360
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"IoT Fleet\" \u2014 device fleet telemetry + alerting, declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 devices \u2192 readings / alerts (resolved by relation)\n// \u2022 functions \u2192 the typed HTTP ingest endpoint + the daily aggregate rollup\n// \u2022 webhooks \u2192 fan alert writes out to the tenant's ops endpoint (outbound\n// subscriptions over the audit_event stream \u2014 webhooks.md \xA72)\n// Cross-row writes (create the reading + heartbeat the device + raise a\n// threshold alert) are exactly why ingest is a FUNCTION, not a Lane-A hook \u2014\n// hooks are pure single-row expressions. Everything here is DATA the tenant\n// owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // Every reading needs a metric name \u2014 a pure function of the row (Lane-A validate).\n reading_metric: {\n collection: 'readings',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.metric) > 0',\n message: 'a reading needs a metric name',\n },\n },\n // The DECLARATIVE rollup alternative to functions/daily-rollup.ts: a\n // `readModels` config entry (cms.md \xA712.4) can materialize the same\n // per-device aggregate on cron into a dedicated rollup collection. This\n // blueprint keeps the imperative function so you can read the endpoint\n // it rides on (POST /v1/cms/items/readings/aggregate, \xA712.2).\n },\n\n // Outbound subscriptions are RUNTIME rows, not config keys:\n // POST /v1/webhooks/subscriptions { target_url, event_prefixes: ['cms.item.'] }\n // fans every cms write (including new alert rows) to your ops endpoint as a\n // signed jobs callback \u2014 verify X-Vxil-Jobs-Signature, filter for alerts.\n webhooks: {},\n\n functions: { enabled: true }, // the \xA77.3 crossing: opt-in, egress-guarded\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n devices: {\n singular: 'device',\n fields: {\n serial: { type: 'string', required: true, indexSlot: 's1', unique: true },\n model: { type: 'string', indexSlot: 's2' },\n site: { type: 'string', indexSlot: 's3' },\n status: { type: 'string', indexSlot: 's4', validation: { enum: ['active', 'maintenance', 'retired'] } },\n last_seen: { type: 'datetime', indexSlot: 't1' }, // heartbeat, PATCHed by ingest-reading\n battery_pct: { type: 'int', indexSlot: 'n1', validation: { min: 0, max: 100 } },\n },\n },\n readings: {\n singular: 'reading',\n fields: {\n device: { type: 'relation', relationTo: 'devices', required: true, indexSlot: 's1' },\n metric: { type: 'string', required: true, indexSlot: 's2' }, // e.g. battery_pct, temp_c\n value: { type: 'float', required: true, indexSlot: 'n1' }, // n-slot \u2192 min/max/avg aggregates\n recorded_at: { type: 'datetime', indexSlot: 't1' }, // t-slot \u2192 the 24h aggregate window\n },\n },\n alerts: {\n singular: 'alert',\n fields: {\n device: { type: 'relation', relationTo: 'devices', required: true, indexSlot: 's1' },\n kind: { type: 'string', required: true, indexSlot: 's2' }, // low_battery | daily_rollup | \u2026\n severity: { type: 'string', indexSlot: 's3', validation: { enum: ['info', 'warning', 'critical'] } },\n raised_at: { type: 'datetime', indexSlot: 't1' },\n note: { type: 'text' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions (functions.md) \u2500\u2500\n functions: {\n // The typed device endpoint: POST /v1/fn/ingest-reading { device_id, metric, value }\n // \u2192 create the reading + heartbeat the device + threshold-alert, in one call.\n // The alert write is deduped with a `lock` + `guard` WRITE BODY (cms.md \xA710):\n // at most ONE live low_battery alert per device \u2014 never a config key.\n 'ingest-reading': {\n entry: './functions/ingest-reading.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // The daily rollup: ONE bounded \xA712.2 aggregate call (per-device min/max/avg\n // over the last 24h of battery_pct readings) \u2192 one info summary row per device\n // per UTC day (lock+guard-deduped \u2014 cron delivery is at-least-once).\n 'daily-rollup': {\n entry: './functions/daily-rollup.ts',\n trigger: { kind: 'cron', schedule: '0 6 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'devices',\n items: [\n { serial: 'SN-0001', model: 'env-sensor-2', site: 'plant-a', status: 'active', battery_pct: 87 },\n { serial: 'SN-0002', model: 'env-sensor-2', site: 'plant-b', status: 'active', battery_pct: 42 },\n ],\n },\n ],\n },\n});\n",
18361
- "readme": '# IoT Fleet / Telemetry template\n\nA device-fleet backend \u2014 devices, telemetry readings, and alerts \u2014 declared end-to-end in one typed\n`vxil.config.ts`, with the domain logic that isn\'t config (ingest, thresholds, rollups) as two tenant\nfunctions.\n\n**What it provisions:**\n- `devices` \u2014 unique serial, model, site, lifecycle status, `last_seen` heartbeat, `battery_pct`.\n- `readings` \u2014 `device` relation, metric name, float `value` (n-slot \u2192 aggregates), `recorded_at` (t-slot \u2192 windows).\n- `alerts` \u2014 `device` relation, kind, severity, `raised_at`, free-text note.\n- Features: `cms` (+ a Lane-A validate hook), `webhooks` (outbound fan-out), `functions`\n (`ingest-reading` on http, `daily-rollup` on a daily cron).\n\n**Apply it:**\n\n```bash\nvxil init --template iot-fleet\nvxil quickstart # or `vxil link` an existing tenant\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **HTTP ingest functions as typed device endpoints** \u2014 `functions/ingest-reading.ts` turns one\n `POST /v1/fn/ingest-reading` (the key needs the `functions:invoke` scope) into three cross-row\n writes: create the reading, PATCH the device heartbeat, raise a threshold alert. Cross-row work\n is exactly why this is a function, not a hook.\n- **Guard-deduped threshold alerting** \u2014 the alert POST carries `lock` + `guard` in the WRITE BODY\n (vxil.com/docs/api: the item write routes): at most one live `low_battery` alert per device, race-safe. Deleting\n the alert row resolves it and frees the guard.\n- **Bounded aggregates for rollups** \u2014 `functions/daily-rollup.ts` calls\n `POST /v1/cms/items/readings/aggregate` (\xA712.2: \u2264500 groups over a \u226450k scan) for per-device\n daily min/max/avg. Cron delivery is at-least-once, so each summary write is itself lock+guard-deduped\n to one per (device, day); the declarative sibling is a `readModels` config entry (\xA712.4).\n- **Webhook fan-out of alerts** \u2014 `POST /v1/webhooks/subscriptions` with\n `{ "target_url": "https://ops.example.com/hook", "event_prefixes": ["cms.item."] }` delivers every\n cms write (including new alerts) as a signed jobs callback \u2014 verify `X-Vxil-Jobs-Signature`.\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ingest-reading \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"device_id":"itm_...","metric":"battery_pct","value":12}\'\n```\n\nTyped SDK query for the ops dashboard: `vx.from(\'devices\').query({ filter: { battery_pct: { $lt: 20 } }, sort: \'-last_seen\' })`.\n\n**Go deeper:** vxil.com/docs/api (lock/guard, aggregates, read-models),\nvxil.com/docs/guide/08-running-your-code-functions, vxil.com/docs/guide/06-feature-catalog (webhooks), and `examples/ecommerce/` for a larger\nfunction saga. The config is yours after `init` \u2014 nothing is locked.\n',
18012
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"IoT Fleet\" \u2014 device fleet telemetry + alerting, declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 devices \u2192 readings / alerts (resolved by relation)\n// \u2022 functions \u2192 the typed HTTP ingest endpoint + the daily aggregate rollup\n// \u2022 webhooks \u2192 fan alert writes out to the tenant's ops endpoint (outbound\n// subscriptions over the audit/event stream (guide ch. 6, webhooks))\n// Cross-row writes (create the reading + heartbeat the device + raise a\n// threshold alert) are exactly why ingest is a FUNCTION, not a Lane-A hook \u2014\n// hooks are pure single-row expressions. Everything here is DATA the tenant\n// owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // Every reading needs a metric name \u2014 a pure function of the row (Lane-A validate).\n reading_metric: {\n collection: 'readings',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.metric) > 0',\n message: 'a reading needs a metric name',\n },\n },\n // The DECLARATIVE rollup alternative to functions/daily-rollup.ts: a\n // `readModels` config entry (guide ch. 4, read models) can materialize the same\n // per-device aggregate on cron into a dedicated rollup collection. This\n // blueprint keeps the imperative function so you can read the endpoint\n // it rides on (POST /v1/cms/items/readings/aggregate, guide ch. 4, aggregates).\n },\n\n // Outbound subscriptions are RUNTIME rows, not config keys:\n // POST /v1/webhooks/subscriptions { target_url, event_prefixes: ['cms.item.'] }\n // fans every cms write (including new alert rows) to your ops endpoint as a\n // signed jobs callback \u2014 verify X-Vxil-Jobs-Signature, filter for alerts.\n webhooks: {},\n\n functions: { enabled: true }, // the functions crossing: opt-in, egress-guarded\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n devices: {\n singular: 'device',\n fields: {\n serial: { type: 'string', required: true, indexSlot: 's1', unique: true },\n model: { type: 'string', indexSlot: 's2' },\n site: { type: 'string', indexSlot: 's3' },\n status: { type: 'string', indexSlot: 's4', validation: { enum: ['active', 'maintenance', 'retired'] } },\n last_seen: { type: 'datetime', indexSlot: 't1' }, // heartbeat, PATCHed by ingest-reading\n battery_pct: { type: 'int', indexSlot: 'n1', validation: { min: 0, max: 100 } },\n },\n },\n readings: {\n singular: 'reading',\n fields: {\n device: { type: 'relation', relationTo: 'devices', required: true, indexSlot: 's1' },\n metric: { type: 'string', required: true, indexSlot: 's2' }, // e.g. battery_pct, temp_c\n value: { type: 'float', required: true, indexSlot: 'n1' }, // n-slot \u2192 min/max/avg aggregates\n recorded_at: { type: 'datetime', indexSlot: 't1' }, // t-slot \u2192 the 24h aggregate window\n },\n },\n alerts: {\n singular: 'alert',\n fields: {\n device: { type: 'relation', relationTo: 'devices', required: true, indexSlot: 's1' },\n kind: { type: 'string', required: true, indexSlot: 's2' }, // low_battery | daily_rollup | \u2026\n severity: { type: 'string', indexSlot: 's3', validation: { enum: ['info', 'warning', 'critical'] } },\n raised_at: { type: 'datetime', indexSlot: 't1' },\n note: { type: 'text' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions (guide ch. 8) \u2500\u2500\n functions: {\n // The typed device endpoint: POST /v1/fn/ingest-reading { device_id, metric, value }\n // \u2192 create the reading + heartbeat the device + threshold-alert, in one call.\n // The alert write is deduped with a `lock` + `guard` WRITE BODY (guide ch. 4, preconditions and concurrency):\n // at most ONE live low_battery alert per device \u2014 never a config key.\n 'ingest-reading': {\n entry: './functions/ingest-reading.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // The daily rollup: ONE bounded aggregate call (per-device min/max/avg\n // over the last 24h of battery_pct readings) \u2192 one info summary row per device\n // per UTC day (lock+guard-deduped \u2014 cron delivery is at-least-once).\n 'daily-rollup': {\n entry: './functions/daily-rollup.ts',\n trigger: { kind: 'cron', schedule: '0 6 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'devices',\n items: [\n { serial: 'SN-0001', model: 'env-sensor-2', site: 'plant-a', status: 'active', battery_pct: 87 },\n { serial: 'SN-0002', model: 'env-sensor-2', site: 'plant-b', status: 'active', battery_pct: 42 },\n ],\n },\n ],\n },\n});\n",
18013
+ "readme": '# IoT Fleet / Telemetry template\n\nA device-fleet backend \u2014 devices, telemetry readings, and alerts \u2014 declared end-to-end in one typed\n`vxil.config.ts`, with the domain logic that isn\'t config (ingest, thresholds, rollups) as two tenant\nfunctions.\n\n**What it provisions:**\n- `devices` \u2014 unique serial, model, site, lifecycle status, `last_seen` heartbeat, `battery_pct`.\n- `readings` \u2014 `device` relation, metric name, float `value` (n-slot \u2192 aggregates), `recorded_at` (t-slot \u2192 windows).\n- `alerts` \u2014 `device` relation, kind, severity, `raised_at`, free-text note.\n- Features: `cms` (+ a Lane-A validate hook), `webhooks` (outbound fan-out), `functions`\n (`ingest-reading` on http, `daily-rollup` on a daily cron).\n\n**Apply it:**\n\n```bash\nvxil init --template iot-fleet\nvxil quickstart # or `vxil link` an existing tenant\nvxil push\nvxil gen\n```\n\n> **Plan note.** The two functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n- **HTTP ingest functions as typed device endpoints** \u2014 `functions/ingest-reading.ts` turns one\n `POST /v1/fn/ingest-reading` (the key needs the `functions:invoke` scope) into three cross-row\n writes: create the reading, PATCH the device heartbeat, raise a threshold alert. Cross-row work\n is exactly why this is a function, not a hook.\n- **Guard-deduped threshold alerting** \u2014 the alert POST carries `lock` + `guard` in the WRITE BODY\n (vxil.com/docs/api: the item write routes): at most one live `low_battery` alert per device, race-safe. Deleting\n the alert row resolves it and frees the guard.\n- **Bounded aggregates for rollups** \u2014 `functions/daily-rollup.ts` calls\n `POST /v1/cms/items/readings/aggregate` (guide ch. 4, aggregates: \u2264500 groups over a \u226450k scan) for per-device\n daily min/max/avg. Cron delivery is at-least-once, so each summary write is itself lock+guard-deduped\n to one per (device, day); the declarative sibling is a `readModels` config entry (guide ch. 4, read models).\n- **Webhook fan-out of alerts** \u2014 `POST /v1/webhooks/subscriptions` with\n `{ "target_url": "https://ops.example.com/hook", "event_prefixes": ["cms.item."] }` delivers every\n cms write (including new alerts) as a signed jobs callback \u2014 verify `X-Vxil-Jobs-Signature`.\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ingest-reading \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"device_id":"itm_...","metric":"battery_pct","value":12}\'\n```\n\nTyped SDK query for the ops dashboard: `vx.from(\'devices\').query({ filter: { battery_pct: { $lt: 20 } }, sort: \'-last_seen\' })`.\n\n**Go deeper:** vxil.com/docs/api (lock/guard, aggregates, read-models),\nvxil.com/docs/guide/08-running-your-code-functions, vxil.com/docs/guide/06-feature-catalog (webhooks), and `examples/ecommerce/` for a larger\nfunction saga. The config is yours after `init` \u2014 nothing is locked.\n',
18362
18014
  "functions": {
18363
- "daily-rollup.ts": "// daily-rollup.ts \u2014 BOUNDED AGGREGATES FOR ROLLUPS (a vxil function, cron trigger).\n//\n// Runs daily (cron 0 6 * * *). ONE call to the real group-by aggregate endpoint \u2014\n// POST /v1/cms/items/readings/aggregate (cms.md \xA712.2) \u2014 computes per-device\n// min/max/avg/count of battery_pct over the last 24h (grouped by the slot-bound\n// `device` relation; hard-bounded server-side: \u2264500 groups over a \u226450k-row scan,\n// else a clean 422 window_too_large), then writes one `daily_rollup` info row into\n// `alerts` per device. Cron delivery is at-least-once and cms does NOT dedupe on\n// the Idempotency-Key header, so exactly-once-per-day is enforced with the \xA710\n// lock+guard WRITE BODY: at most ONE daily_rollup per (device, UTC day) \u2014 a\n// redelivered run cleanly 409s. The DECLARATIVE alternative is a `readModels`\n// config entry materializing on cron (cms.md \xA712.4).\n\n// cron-walk: single-read \u2014 one bounded server-side aggregate is the whole job; nothing to page.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Group { key: { device?: string }; count: number; min?: number; max?: number; avg?: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = new Date().toISOString();\n\n // 1. the bounded aggregate: min/max/avg need `value` on an n-slot (n1);\n // the 24h window rides the t-slot (t1) on `recorded_at`.\n const agg = await fetch(`${base}/v1/cms/items/readings/aggregate`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n aggregates: [\n { fn: 'min', field: 'value', as: 'min' },\n { fn: 'max', field: 'value', as: 'max' },\n { fn: 'avg', field: 'value', as: 'avg' },\n { fn: 'count' },\n ],\n groupBy: ['device'],\n filter: { metric: 'battery_pct', $status: 'published' },\n window: { field: 'recorded_at', sinceDays: 1 },\n limit: 500,\n }),\n });\n if (!agg.ok) return json({ error: 'aggregate_failed', status: agg.status }, 502);\n const groups = ((await agg.json()) as { data?: { groups?: Group[] } }).data?.groups ?? [];\n\n // 2. one published summary row per (device, UTC day) \u2014 the guard counts today's\n // live daily_rollup rows for the device under the lock, so a redelivered\n // cron run 409s instead of duplicating (cms.md \xA710).\n const day = now.slice(0, 10);\n let written = 0;\n for (const g of groups) {\n if (!g.key.device) continue;\n const r = await fetch(`${base}/v1/cms/items/alerts`, {\n method: 'POST',\n headers: env.idempotency_key ? { ...H, 'idempotency-key': `${env.idempotency_key}:${g.key.device}` } : H,\n body: JSON.stringify({\n status: 'published',\n lock: `rollup:${g.key.device}`,\n guard: {\n filter: { device: g.key.device, kind: 'daily_rollup', raised_at: { $gte: `${day}T00:00:00.000Z` } },\n max: 1,\n },\n data: {\n device: g.key.device, kind: 'daily_rollup', severity: 'info', raised_at: now,\n note: `battery_pct last 24h \u2014 min ${g.min} \xB7 max ${g.max} \xB7 avg ${round1(g.avg)} over ${g.count} readings`,\n },\n }),\n });\n if (r.ok) written += 1;\n }\n return json({ devices: groups.length, written }, 200);\n },\n};\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\nconst round1 = (n?: number) => (typeof n === 'number' ? Math.round(n * 10) / 10 : n);\n",
18364
- "ingest-reading.ts": "// ingest-reading.ts \u2014 THE TYPED DEVICE ENDPOINT (a vxil function, http trigger).\n//\n// POST /v1/fn/ingest-reading with { device_id, metric, value } \u2014 one call from a\n// device (or gateway) does the three cross-row writes no Lane-A hook may do:\n// 1. create the `readings` row (published, so the daily aggregate window sees it;\n// the envelope idempotency key is passed through as the write's Idempotency-Key\n// header \u2014 the platform convention, functions.md \xA76e)\n// 2. PATCH the device: last_seen = now (+ battery_pct when the metric carries it)\n// 3. THRESHOLD ALERTING: battery below 15 \u2192 create a low_battery alert, deduped\n// by a lock+guard WRITE BODY (at most ONE live alert per device \u2014 cms.md \xA710).\n// The guard is the dedupe that matters \u2014 a retried reading create is telemetry noise.\n// Resolve an alert by deleting its row; that frees the guard for the next one.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload` (functions.md \xA72)\ntype Env = HttpFunctionEnvelope<{ device_id?: string; metric?: string; value?: number }>;\ninterface Created { data?: { item_id?: string } } // cms responses are { data: {\u2026}, meta }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const { device_id, metric } = env.payload ?? {};\n const value = Number(env.payload?.value);\n if (!device_id || !metric || !Number.isFinite(value)) {\n return json({ error: 'device_id, metric and numeric value required' }, 400);\n }\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = new Date().toISOString();\n\n // 0. the device must exist (404 from cms = unknown or deleted device)\n const dev = await fetch(`${base}/v1/cms/items/devices/${device_id}`, { headers: H });\n if (!dev.ok) return json({ error: 'unknown_device' }, 404);\n\n // 1. create the reading \u2014 published so the daily aggregate window sees it\n const rd = await fetch(`${base}/v1/cms/items/readings`, {\n method: 'POST',\n headers: env.idempotency_key ? { ...H, 'idempotency-key': env.idempotency_key } : H,\n body: JSON.stringify({\n status: 'published',\n data: { device: device_id, metric, value, recorded_at: now },\n }),\n });\n if (!rd.ok) return json({ error: 'reading_create_failed', status: rd.status }, 502);\n const reading = ((await rd.json()) as Created).data ?? {};\n\n // 2. heartbeat the device (merge-patch; battery only when this metric carries it)\n const patch: Record<string, unknown> = { last_seen: now };\n if (metric === 'battery_pct') patch.battery_pct = Math.max(0, Math.min(100, Math.round(value)));\n await fetch(`${base}/v1/cms/items/devices/${device_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data: patch }),\n });\n\n // 3. threshold alert \u2014 `guard` counts live rows under the ONE advisory `lock`,\n // so N racing low-battery ingests raise exactly one alert (the rest 409).\n let alert_id: string | undefined;\n if (metric === 'battery_pct' && value < 15) {\n const al = await fetch(`${base}/v1/cms/items/alerts`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n status: 'published',\n lock: `dev:${device_id}:low_battery`,\n guard: { filter: { device: device_id, kind: 'low_battery' }, max: 1 },\n data: { device: device_id, kind: 'low_battery', severity: 'warning', raised_at: now, note: `battery at ${value}%` },\n }),\n });\n if (al.ok) alert_id = ((await al.json()) as Created).data?.item_id;\n // 409 guard_failed \u2192 an open low_battery alert already exists; nothing to do.\n }\n\n return json({ reading_id: reading.item_id, alert_id }, 201);\n },\n};\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n"
18015
+ "daily-rollup.ts": "// daily-rollup.ts \u2014 BOUNDED AGGREGATES FOR ROLLUPS (a vxil function, cron trigger).\n//\n// Runs daily (cron 0 6 * * *). ONE call to the real group-by aggregate endpoint \u2014\n// POST /v1/cms/items/readings/aggregate (guide ch. 4, aggregates) \u2014 computes per-device\n// min/max/avg/count of battery_pct over the last 24h (grouped by the slot-bound\n// `device` relation; hard-bounded server-side: \u2264500 groups over a \u226450k-row scan,\n// else a clean 422 window_too_large), then writes one `daily_rollup` info row into\n// `alerts` per device. Cron delivery is at-least-once and cms does NOT dedupe on\n// the Idempotency-Key header, so exactly-once-per-day is enforced with the lock + guard\n// lock+guard WRITE BODY: at most ONE daily_rollup per (device, UTC day) \u2014 a\n// redelivered run cleanly 409s. The DECLARATIVE alternative is a `readModels`\n// config entry materializing on cron (guide ch. 4, read models).\n\n// cron-walk: single-read \u2014 one bounded server-side aggregate is the whole job; nothing to page.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Group { key: { device?: string }; count: number; min?: number; max?: number; avg?: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = new Date().toISOString();\n\n // 1. the bounded aggregate: min/max/avg need `value` on an n-slot (n1);\n // the 24h window rides the t-slot (t1) on `recorded_at`.\n const agg = await fetch(`${base}/v1/cms/items/readings/aggregate`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n aggregates: [\n { fn: 'min', field: 'value', as: 'min' },\n { fn: 'max', field: 'value', as: 'max' },\n { fn: 'avg', field: 'value', as: 'avg' },\n { fn: 'count' },\n ],\n groupBy: ['device'],\n filter: { metric: 'battery_pct', $status: 'published' },\n window: { field: 'recorded_at', sinceDays: 1 },\n limit: 500,\n }),\n });\n if (!agg.ok) return json({ error: 'aggregate_failed', status: agg.status }, 502);\n const groups = ((await agg.json()) as { data?: { groups?: Group[] } }).data?.groups ?? [];\n\n // 2. one published summary row per (device, UTC day) \u2014 the guard counts today's\n // live daily_rollup rows for the device under the lock, so a redelivered\n // cron run 409s instead of duplicating (guide ch. 4, preconditions and concurrency).\n const day = now.slice(0, 10);\n let written = 0;\n for (const g of groups) {\n if (!g.key.device) continue;\n const r = await fetch(`${base}/v1/cms/items/alerts`, {\n method: 'POST',\n headers: env.idempotency_key ? { ...H, 'idempotency-key': `${env.idempotency_key}:${g.key.device}` } : H,\n body: JSON.stringify({\n status: 'published',\n lock: `rollup:${g.key.device}`,\n guard: {\n filter: { device: g.key.device, kind: 'daily_rollup', raised_at: { $gte: `${day}T00:00:00.000Z` } },\n max: 1,\n },\n data: {\n device: g.key.device, kind: 'daily_rollup', severity: 'info', raised_at: now,\n note: `battery_pct last 24h \u2014 min ${g.min} \xB7 max ${g.max} \xB7 avg ${round1(g.avg)} over ${g.count} readings`,\n },\n }),\n });\n if (r.ok) written += 1;\n }\n return json({ devices: groups.length, written }, 200);\n },\n};\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\nconst round1 = (n?: number) => (typeof n === 'number' ? Math.round(n * 10) / 10 : n);\n",
18016
+ "ingest-reading.ts": "// ingest-reading.ts \u2014 THE TYPED DEVICE ENDPOINT (a vxil function, http trigger).\n//\n// POST /v1/fn/ingest-reading with { device_id, metric, value } \u2014 one call from a\n// device (or gateway) does the three cross-row writes no Lane-A hook may do:\n// 1. create the `readings` row (published, so the daily aggregate window sees it;\n// the envelope idempotency key is passed through as the write's Idempotency-Key\n// header \u2014 the platform convention, guide ch. 8)\n// 2. PATCH the device: last_seen = now (+ battery_pct when the metric carries it)\n// 3. THRESHOLD ALERTING: battery below 15 \u2192 create a low_battery alert, deduped\n// by a lock+guard WRITE BODY (at most ONE live alert per device \u2014 guide ch. 4, preconditions and concurrency).\n// The guard is the dedupe that matters \u2014 a retried reading create is telemetry noise.\n// Resolve an alert by deleting its row; that frees the guard for the next one.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload` (guide ch. 8)\ntype Env = HttpFunctionEnvelope<{ device_id?: string; metric?: string; value?: number }>;\ninterface Created { data?: { item_id?: string } } // cms responses are { data: {\u2026}, meta }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const { device_id, metric } = env.payload ?? {};\n const value = Number(env.payload?.value);\n if (!device_id || !metric || !Number.isFinite(value)) {\n return json({ error: 'device_id, metric and numeric value required' }, 400);\n }\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = new Date().toISOString();\n\n // 0. the device must exist (404 from cms = unknown or deleted device)\n const dev = await fetch(`${base}/v1/cms/items/devices/${device_id}`, { headers: H });\n if (!dev.ok) return json({ error: 'unknown_device' }, 404);\n\n // 1. create the reading \u2014 published so the daily aggregate window sees it\n const rd = await fetch(`${base}/v1/cms/items/readings`, {\n method: 'POST',\n headers: env.idempotency_key ? { ...H, 'idempotency-key': env.idempotency_key } : H,\n body: JSON.stringify({\n status: 'published',\n data: { device: device_id, metric, value, recorded_at: now },\n }),\n });\n if (!rd.ok) return json({ error: 'reading_create_failed', status: rd.status }, 502);\n const reading = ((await rd.json()) as Created).data ?? {};\n\n // 2. heartbeat the device (merge-patch; battery only when this metric carries it)\n const patch: Record<string, unknown> = { last_seen: now };\n if (metric === 'battery_pct') patch.battery_pct = Math.max(0, Math.min(100, Math.round(value)));\n await fetch(`${base}/v1/cms/items/devices/${device_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data: patch }),\n });\n\n // 3. threshold alert \u2014 `guard` counts live rows under the ONE per-key `lock`,\n // so N racing low-battery ingests raise exactly one alert (the rest 409).\n let alert_id: string | undefined;\n if (metric === 'battery_pct' && value < 15) {\n const al = await fetch(`${base}/v1/cms/items/alerts`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n status: 'published',\n lock: `dev:${device_id}:low_battery`,\n guard: { filter: { device: device_id, kind: 'low_battery' }, max: 1 },\n data: { device: device_id, kind: 'low_battery', severity: 'warning', raised_at: now, note: `battery at ${value}%` },\n }),\n });\n if (al.ok) alert_id = ((await al.json()) as Created).data?.item_id;\n // 409 guard_failed \u2192 an open low_battery alert already exists; nothing to do.\n }\n\n return json({ reading_id: reading.item_id, alert_id }, 201);\n },\n};\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n"
18365
18017
  }
18366
18018
  },
18367
18019
  {
@@ -18381,8 +18033,8 @@ export default defineConfig({
18381
18033
  ],
18382
18034
  "hasFunctions": true,
18383
18035
  "byoKeys": [],
18384
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Push notifications\" \u2014 DEVICE TOKENS DONE PROPERLY.\n//\n// Sending a push is the easy part: one HTTPS POST to your push service. What\n// actually breaks in production is the REGISTRY \u2014 the mapping from a user to\n// the devices they are currently holding. Tokens rotate, phones get sold, users\n// sign out and a colleague signs in on the same handset, and a token that once\n// belonged to Alice starts delivering Alice's notifications to Bob. That is a\n// privacy incident, not a bug.\n//\n// So this blueprint is a registry with three invariants and a small amount of\n// sending bolted on \u2014 and it is THE supported Expo path on vxil (guide 17):\n// 1. ONE TOKEN, ONE USER \u2014 `token` is `unique`, enforced by the platform.\n// 2. RE-OWN ON CHANGE \u2014 a token that reappears under a different user is\n// REASSIGNED, never duplicated. From your backend (server mode) that is\n// immediate; from the app itself (end-user mode, which cannot see another\n// user's row) it is ASYNC: a claim row + a server-mode cmsHook.\n// 3. PRUNE STALE \u2014 the push service tells you when a token is dead; a cron\n// collects those receipts and deletes the rows, so your registry shrinks.\n// It walks the WHOLE registry over successive ticks (a persisted cursor).\n// Plus the two things every consumer app ends up writing:\n// \u2022 ERASURE \u2014 a user erased under GDPR (auth.user.erased / user.erased) or\n// deleted (user.deleted) loses every device row, claim and preferences\n// row \u2014 never on session.revoked, which fires on every refresh.\n// \u2022 A REMINDER LADDER \u2014 an hourly cron nudging inactive users (1 d, 3 d, 7 d),\n// honouring each user's opt-out and quiet hours in THEIR time zone.\n//\n// \u2022 cms \u2192 `push_tokens` (owner-scoped, `token` unique), `push_token_claims`\n// (an app's async re-own request), `push_prefs` (reminders,\n// time zone, quiet hours, last activity), `push_state` (cursors)\n// \u2022 functions \u2192 `register-token` (the registry: http + the claim cmsHook + the\n// erasure webhooks), `send-push` (http send + the hourly ladder\n// cron), `prune-receipts` (the receipts cron)\n//\n// Three functions and two crons (the fastest hourly) on purpose: that is inside\n// the Free plan's function limits for staging and development projects (guide\n// 12 has the per-plan table), so the whole kit runs on a free project while\n// you build the app. Every tick is bounded and resumable, so a tick the plan\n// sheds is harmless: the next one picks up where the last stopped.\n//\n// Written against Expo's push service because it fronts BOTH APNs and FCM with\n// one token format. Swapping in raw FCM or APNs is a change to two constants\n// and the message body \u2014 see README.md.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Fail-safe: a verified end-user may only touch collections that declare\n // an ownerField. `push_tokens` does, so a signed-in device can read and\n // refresh its OWN rows and structurally cannot enumerate anyone else's.\n strictEndUserScope: true,\n hooks: {\n // A row with no token is a row that can never receive anything.\n token_present: {\n collection: 'push_tokens',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.token) > 0',\n message: 'a push token is required',\n },\n // The ladder walk filters on `ladder_step < 3` (a slot compare, which\n // never matches an absent value), so every prefs row carries one: a\n // write that leaves it out gets 0.\n ladder_step_default: {\n collection: 'push_prefs',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'ladder_step',\n expr: 'coalesce(item.ladder_step, 0)',\n },\n },\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n push_tokens: {\n singular: 'push_token',\n // Owner-scoped: an end-user session sees only its own devices.\n ownerField: 'end_user',\n fields: {\n // \u2605 INVARIANT 1. `unique` is the TOP-LEVEL field attribute (there is no\n // `validation.unique`). The platform backs it with a claims table, so a\n // second registration of the same token is a 409 `unique_violation` \u2014\n // atomically, under concurrency, without a read-then-write race.\n // NOTE: a unique value is capped at 256 characters. Expo tokens\n // (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside that;\n // if you switch to raw FCM registration tokens, store a hash here and\n // keep the full token in an unindexed `text` field.\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' }, // the owner\n platform: { type: 'string', indexSlot: 's3', validation: { enum: ['ios', 'android', 'web'] } },\n // The device's language, captured at registration \u2014 this is what makes\n // a localized push possible without a second lookup per device.\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'fr', \u2026\n // The receipt id returned by the LAST send. `prune-receipts` trades it\n // for a verdict and deletes the row when the service says the device\n // is gone.\n receipt_id: { type: 'string' },\n // When that receipt id was parked. The push service keeps a receipt\n // for a limited time; past RECEIPT_MAX_AGE the id is cleared as\n // unknowable instead of being asked about forever.\n receipt_at: { type: 'datetime' },\n // Consecutive delivery failures \u2014 a soft signal for your own dashboards\n // (the authoritative kill signal is the receipt, not this counter).\n failures: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_seen: { type: 'datetime', indexSlot: 't1' }, // last registration/refresh\n checked_at: { type: 'datetime', indexSlot: 't2' }, // last receipt check\n last_error: { type: 'text' },\n },\n },\n\n // \u2605 INVARIANT 2, the app's half. An end-user session cannot see \u2014 let\n // alone re-own \u2014 a row that belongs to someone else, so when the app\n // registers a token another user holds, `register-token` files a CLAIM\n // here (owned by the caller) and answers 202. The `register-token`\n // cmsHook binding then runs in SERVER mode, moves the token, and deletes\n // the claim. Usually done within seconds.\n push_token_claims: {\n singular: 'push_token_claim',\n ownerField: 'end_user',\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1' },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3', validation: { enum: ['ios', 'android', 'web'] } },\n locale: { type: 'string', indexSlot: 's4' },\n },\n },\n\n // The reminder ladder's per-user state. Owner-scoped: the app reads and\n // writes its OWN row (opt out, set quiet hours, stamp `last_active` and\n // reset `ladder_step` to 0 whenever the app comes to the foreground).\n push_prefs: {\n singular: 'push_pref',\n ownerField: 'end_user',\n fields: {\n end_user: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // IANA zone ('Asia/Riyadh'); quiet hours are evaluated in it. An\n // unknown zone is treated as UTC.\n timezone: { type: 'string', indexSlot: 's2' },\n // false = no reminders at all (absent = reminders on).\n reminders: { type: 'bool' },\n // Local hours [quiet_start, quiet_end) during which nothing is sent;\n // the window may wrap midnight (22 \u2192 8). Equal values = no quiet hours.\n quiet_start: { type: 'int', validation: { min: 0, max: 23 } },\n quiet_end: { type: 'int', validation: { min: 0, max: 23 } },\n last_active: { type: 'datetime', indexSlot: 't1' },\n // The next rung (0..3) since `last_active` (the app resets it to 0; the\n // `ladder_step_default` derive writes 0 when a write leaves it out).\n ladder_step: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_reminded_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n\n // Where each cron left off (`key` = the job). No ownerField, so with\n // `strictEndUserScope` an end-user session cannot touch it at all.\n push_state: {\n singular: 'push_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // the last item id walked; '' = start again from the newest row\n cursor: { type: 'string' },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n functions: {\n // THE REGISTRY \u2014 one bundle, five front doors (a function is code + a SET\n // of trigger bindings):\n // \u2022 http POST /v1/fn/register-token { token, user_id?, platform?, locale? }\n // server mode (your backend) re-owns at once; end-user mode\n // (the app) files a claim for a token someone else holds \u2192 202.\n // \u2022 cmsHook a new `push_token_claims` row \u2192 re-own it in SERVER mode,\n // delete the claim. `retry` re-delivers a failed attempt.\n // \u2022 webhook `auth.user.erased` (the auth erase), `user.erased` (the\n // admin erase) and `user.deleted` (the admin delete) \u2192 delete\n // every device row, claim and preferences row of that user.\n // NOT `auth.session.revoked`: that fires on every refresh-token\n // rotation, and would sign devices out of push at random.\n 'register-token': {\n entry: './functions/register-token.ts',\n trigger: { kind: 'http' },\n triggers: [\n { kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'auth.user.erased', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'user.erased', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'user.deleted', retry: { maxAttempts: 3 } },\n ],\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // pure vxil-internal\n },\n\n // THE SENDER \u2014 two front doors:\n // \u2022 http POST /v1/fn/send-push { user_ids, messages, data? } \u2014 resolves\n // each user's live devices, picks the message for each device's\n // locale, and posts to the push service in batches.\n // \u2022 cron hourly: THE REMINDER LADDER. Users inactive 1 d / 3 d / 7 d get\n // at most one nudge per step (a user found already a week idle gets\n // only the 7-day one), never during their quiet hours, never after\n // opting out. Each step is claimed (If-Match) BEFORE the send, so\n // an overlapping tick can never send it twice.\n 'send-push': {\n entry: './functions/send-push.ts',\n trigger: { kind: 'http' },\n triggers: [{ kind: 'cron', schedule: '0 * * * *' }],\n scopes: ['cms:read', 'cms:write'],\n // Deny-by-default egress. `exp.host` is Expo's push host; `api.expo.dev`\n // is its newer alias. For raw FCM use 'fcm.googleapis.com'; for APNs,\n // 'api.push.apple.com' (and see README.md on the token-auth swap).\n egressAllow: ['exp.host', 'api.expo.dev'],\n // NO `secrets` here on purpose. Expo's push API needs no credential in its\n // default configuration, and secret resolution is FAIL-CLOSED: a declared\n // `secret:<name>` ref with no stored value makes every invocation\n // `409 secret_missing` before your code runs. Declare a secret only once\n // you will actually set it \u2014 see README.md \xA7\"If your push service needs a key\".\n },\n\n // \u2605 INVARIANT 3. THE GARBAGE COLLECTOR. A push service accepts a send and\n // only later tells you the device was gone \u2014 that verdict arrives as a\n // RECEIPT, not as the send response. Every 6 hours this cron trades parked\n // receipt ids for verdicts and deletes the rows the service declared dead.\n // It resumes where the last tick stopped (`push_state`), so a registry of\n // any size is walked end to end.\n 'prune-receipts': {\n entry: './functions/prune-receipts.ts',\n trigger: { kind: 'cron', schedule: '30 */6 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: ['exp.host', 'api.expo.dev'],\n },\n },\n});\n",
18385
- "readme": "# Push notifications (mobile)\n\nSending a push is the easy part: one HTTPS POST to your push service. What breaks in production is the\n**registry** \u2014 the mapping from a user to the devices they are currently holding.\n\nTokens rotate. Phones get sold. A user signs out and a colleague signs in on the same handset. Unless\nsomething reassigns that token, the previous owner's notifications keep arriving on someone else's lock\nscreen \u2014 a privacy incident, not a bug. And nobody collects delivery **receipts**, so the registry only\never grows: dead tokens are re-sent to forever and delivery rates look worse than they are.\n\nThis blueprint is that registry, with three invariants, plus the sending, the erasure clean-up and the\nre-engagement ladder every consumer app ends up writing. It is **the supported Expo path on vxil** \u2014\nvxil holds no push credential and runs no push service; your functions call Expo (which fronts APNs and\nFCM) through the egress allowlist. The whole mobile picture is in\n[guide 17 \u2014 vxil for Expo apps](../../docs/guide/17-mobile-apps-with-expo.md).\n\n```bash\nvxil init --template push-notifications\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # four collections + three functions (two of them crons)\n```\n\nThree functions and two crons (the fastest hourly) on purpose: that is inside the Free plan's function\nlimits for staging and development projects (the per-plan table is in guide 12), so a free project runs\nall of it while you build the app. Every cron tick is bounded and resumable, so a tick the plan sheds is\nharmless \u2014 the next one picks up where the last stopped.\n\n## The three invariants\n\n| # | Invariant | Where it is enforced |\n|---|---|---|\n| **1** | **One token, one user** | `token: { unique: true }` \u2014 the *top-level* field attribute, backed by a claims table. A second registration of the same token is a `409 unique_violation`, atomically, with no read-then-write race. |\n| **2** | **Re-own on change** | `functions/register-token.ts` \u2014 on that 409 it looks the row up and **PATCHes the owner** with `If-Match`. Reassign, never duplicate. From the app itself (end-user mode) the move is **async**: a claim row + a server-mode `cmsHook` (below). |\n| **3** | **Prune stale** | `functions/prune-receipts.ts` \u2014 a cron every 6 hours trades parked receipt ids for verdicts and **deletes** every row the service reports as `DeviceNotRegistered`. It resumes where the last tick stopped, so the **whole** registry is walked, whatever its size. |\n\nThere is no `validation: { unique: true }` \u2014 that key does not exist and would be silently ignored.\nUniqueness is the top-level `unique` attribute, on scalar field types only.\n\n## The registry\n\n```ts\npush_tokens: {\n ownerField: 'end_user', // a signed-in device sees only its own rows\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3' }, // ios | android | web\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'pt-BR', \u2026\n receipt_id: { type: 'string' }, // the last send's pending verdict\n receipt_at: { type: 'datetime' }, // when it was parked (48 h \u2192 cleared)\n failures: { type: 'int', indexSlot: 'n1' },\n last_seen: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_error: { type: 'text' },\n },\n}\n```\n\nBeside it: `push_token_claims` (the app's async re-own requests, owner-scoped), `push_prefs` (one row per\nuser for the reminder ladder, owner-scoped, `end_user` unique) and `push_state` (each cron's cursor \u2014 no\n`ownerField`, so no end-user session can touch it).\n\n`strictEndUserScope: true` is on, so a verified end-user session may only touch collections that declare\nan `ownerField`. `push_tokens` does \u2014 a device can refresh **its own** rows and structurally cannot\nenumerate anyone else's.\n\n> **The 256-character cap.** A unique value is capped at 256 characters. Expo tokens\n> (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside it. If you switch to raw **FCM\n> registration tokens**, store a hash in `token` and keep the full value in an unindexed `text` field \u2014\n> otherwise long tokens are rejected at write time.\n\n## Registering \u2014 from your backend or from the app\n\n`POST /v1/fn/register-token { token, user_id?, platform?, locale? }`\n\nInvariant 2 needs to reassign a token **away from another user**, which needs a tenant-wide read. A\nfunction invoked with a verified end-user session gets **owner-scoped** callback tokens \u2014 the platform\nstamps the principal onto every scoped token the function mints downstream \u2014 so in end-user mode the\nother user's row is simply invisible. That is the fail-safe working, not a gap. So there are two paths:\n\n| Called from | `user_id` | A token another user holds | Response |\n|---|---|---|---|\n| **your backend** (server key) | from the body | re-owned at once (`If-Match`) | `200 {action:'reowned', previous_owner}` |\n| **the app** (end-user session) | the **verified** session id \u2014 a body `user_id` is ignored | a **claim** row in `push_token_claims`, owned by the caller | `202 {action:'reown_queued', claim_id}` |\n\nEither way a new token is `201 {action:'created'}` and an unchanged owner is `200 {action:'refreshed'}`.\n\n**The async re-own.** `register-token` declares a second binding,\n`{ kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } }`.\nA platform-delivered hook carries **no** end-user, so it runs in server mode: it re-reads the claim,\nre-owns the token row for the claimant (or creates it, if the old holder deleted it meanwhile), and\ndeletes the claim. It usually lands within seconds; a failed attempt answers non-2xx and the `retry`\nre-delivers it, and every step is idempotent (a claim that is already gone is done).\n\n**What a claim can and cannot do.** A claim can only name its caller as the new owner (the platform\nstamps the verified owner on the row), and a push token is a bearer credential only the device itself is\nhanded by the OS. Claiming a token you do not hold moves **your** notifications to that device \u2014 no data\nreaches you. The trade-off to know about is availability: a signed-in user who somehow learned another\nuser's Expo token could move that device away from its owner, who then stops receiving pushes (security\nalerts included) until their app starts again and re-registers it. Tokens are not shown to other users\nby anything in this kit, so that needs the token to have leaked. If your app treats pushes as critical,\nadd a `validate` hook on `push_token_claims` that caps open claims, or have the app re-register on every\nforeground (it is idempotent \u2014 `200 {action:'refreshed'}` costs one write).\n\n**Sign-out.** A device that signs out should stop receiving the old user's pushes before anyone else\nsigns in: delete the device's own row from the app (`DELETE /v1/cms/items/push_tokens/{item_id}` with\nthe session \u2014 an owner may delete its own rows), or just let the next sign-in re-own it.\n\n## Erasure and deletion \u2014 `auth.user.erased`, not `session.revoked`\n\n`register-token` also binds `{ kind: 'webhook', source: 'auth.user.erased' }`,\n`{ kind: 'webhook', source: 'user.erased' }` (the admin `DELETE /v1/users/{id}?erase=true`) and\n`{ kind: 'webhook', source: 'user.deleted' }` (the admin delete without erase). That user loses every\n`push_tokens` row, pending claim and their `push_prefs` row (time zone, quiet hours, activity) \u2014 device\ntokens and preferences are personal data, vxil's erasure scrubs its own tables, not your collections,\nand a deleted account must stop receiving reminder nudges.\n\nA **merge or rekey** (`auth.user.merged` / `auth.user.rekeyed` \u2014 a guest becoming a registered user)\nneeds no binding: the app's next start registers its token under the new id, which re-owns the device.\nThe old id's prefs row goes quiet on its own (it has no devices left to nudge).\n\nDo **not** clean up on `auth.session.revoked`: it fires on every refresh-token rotation as well as on\nsign-out, so a cleanup bound to it would unregister devices at random.\n\n## Sending \u2014 localized per device, not per user\n\n`POST /v1/fn/send-push`\n\n```json\n{\n \"user_ids\": [\"usr_a\", \"usr_b\"],\n \"messages\": {\n \"default\": { \"title\": \"You're in\", \"body\": \"Pro is active on this device.\" },\n \"ar\": { \"title\": \"\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644\", \"body\": \"\u0623\u0635\u0628\u062D \u0627\u0634\u062A\u0631\u0627\u0643 Pro \u0645\u0641\u0639\u0651\u0644\u0627\u064B.\" },\n \"fr\": { \"title\": \"C'est actif\", \"body\": \"Pro est activ\xE9 sur cet appareil.\" }\n },\n \"data\": { \"screen\": \"billing\" }\n}\n```\n\nOne person can carry an English phone and an Arabic tablet. Because `locale` lives on the **token row**,\neach device gets the right copy with no extra lookup \u2014 exact match (`pt-BR`), then the base language\n(`pt`), then `default`.\n\nThe response distinguishes the two failure kinds, because they call for opposite actions:\n\n- an **immediate** `DeviceNotRegistered` \u2192 the token is dead now; the row is deleted;\n- anything else (rate limits, a message too big, a transient fault) \u2192 counted in `failures`, device kept.\n Deleting a device because one message was malformed is how a registry loses real users.\n- an **accepted** message returns a *receipt id*, not a delivery. That id is parked on the row, and the\n real verdict is collected by the cron below.\n\n## The receipts cron \u2014 the loop almost nobody closes\n\n`prune-receipts` runs at `30 */6 * * *`. It resumes the walk of `push_tokens` where the previous tick\nstopped (its cursor lives in `push_state`), collects the rows carrying a `receipt_id`, asks the push\nservice for those receipts in batches, and acts \u2014 `ok` clears the id, `DeviceNotRegistered` deletes the\nrow, anything else counts a failure and keeps the device. A receipt that is not ready yet is simply\nabsent from the response, which is not an error; it is asked again on the next walk. A receipt id parked\nfor more than 48 hours with no answer is cleared \u2014 the service no longer keeps it, and asking forever\nwould pin the row.\n\nEverything is bounded: one tick reads at most 2,000 rows and acts on at most 300, so its outbound calls\nstay in the low hundreds. Because the cursor persists, a registry of **any** size is walked end to end\nover successive ticks; when the walk reaches the oldest row the cursor resets and the next tick starts\nagain from the newest (rows registered during a walk are covered by the next one).\n\n> **A paging detail worth stealing.** The cron pages with `?cursor=` and **no** `?sort=`. In the\n> default order (newest first) the cursor is a plain item id, so it stays valid across ticks \u2014 persist it\n> and the walk resumes exactly where it stopped.\n\n**Never clears a newer receipt.** Each clear is a `PATCH` with `If-Match` on the row version the walk\nread. If `send-push` parked a newer receipt id on the same device in between, the clear is refused and\ncounted as `raced`; the newer id is checked on the next walk.\n\n## The reminder ladder \u2014 re-engagement that respects the user\n\n`send-push` has a second binding, `{ kind: 'cron', schedule: '0 * * * *' }`. Every hour it nudges users\nwho stopped opening the app: once after **1 day**, once after **3**, once after **7** \u2014 then silence until\nthey come back. The copy lives in the `LADDER` constant at the top of `functions/send-push.ts` (per\nlocale, picked per device like any send).\n\nIt reads `push_prefs`, a row per user **the app writes itself** (owner-scoped):\n\n| Field | Meaning |\n|---|---|\n| `reminders` | `false` = never. Absent = on. |\n| `timezone` | IANA zone (`Asia/Riyadh`); quiet hours are evaluated in it. Unknown = UTC. |\n| `quiet_start`, `quiet_end` | local hours `[start, end)` with nothing sent; may wrap midnight (`22` \u2192 `8`). Equal = none. |\n| `last_active` | stamp it whenever the app comes to the foreground\u2026 |\n| `ladder_step` | \u2026and reset this to `0` at the same time. The cron advances it. Left out on a write, a `derive` hook stores `0`. |\n\n```ts\n// in the app, on AppState 'active' (end-user session)\nawait vx.from('push_prefs').patch(prefsId, { last_active: new Date().toISOString(), ladder_step: 0 });\n```\n\nThree guarantees:\n\n- **Only idle users are read.** The walk filters on `last_active` and `ladder_step < 3` (both indexed),\n so active users and users who finished the ladder cost nothing; it resumes where the last tick stopped\n (`push_state`), reading at most 2,000 rows and nudging at most 100 users per tick. Users who opted out\n are still read (an absent `reminders` means on, which a filter cannot express) and skipped.\n- **Never twice, never a burst.** Each step is claimed on the prefs row with `If-Match` **before** the\n send \u2014 two overlapping ticks cannot both send it. A send that fails after the claim is a missed nudge,\n never a duplicate one. When several rungs are due at once (the kit added to a live app, reminders\n turned back on, a cron that was held), only the **highest** is sent and the ones below are skipped; and\n a rung waits its own gap after the previous nudge (`last_reminded_at`), so the 3-day and 7-day nudges\n stay two and four days after the one before.\n- **Never at night.** A user inside their quiet hours is skipped and picked up by a later tick once the\n window has passed (the next one, unless the walk spans more than one tick).\n\n## Example: post-purchase push on `payments.entitlement.changed`\n\nThe payments feature writes `payments.entitlement.changed` to your audit stream on every entitlement\ntransition, carrying `end_user_id`, the previous and new tier, `status`, `until`, `reason` and\n`environment`. Turning that into \"your Pro plan is live, in the language of the device\" is a wiring\nexercise, not new code \u2014 there are two shapes, both using the same `send-push` call:\n\n**(a) Subscribe and call in.** Outbound subscriptions are runtime rows, not config:\n\n```bash\ncurl -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H \"authorization: Bearer $VXIL_API_KEY\" -H 'content-type: application/json' \\\n -d '{\"target_url\":\"https://your-app.example.com/hooks/vxil\",\"event_prefixes\":[\"payments.entitlement.\"]}'\n```\n\nYour endpoint verifies the signed delivery, then calls `send-push` with a key holding\n`functions:invoke`:\n\n```ts\n// inside your webhook receiver \u2014 event.payload.end_user_id / .tier are the fields above\nawait fetch('https://api.vxil.com/v1/fn/send-push', {\n method: 'POST',\n headers: { authorization: `Bearer ${process.env.VXIL_API_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_ids: [event.payload.end_user_id],\n messages: {\n default: { title: 'You\\'re in', body: `${event.payload.tier} is active on this device.` },\n ar: { title: '\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644', body: `\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 ${event.payload.tier} \u0639\u0644\u0649 \u0647\u0630\u0627 \u0627\u0644\u062C\u0647\u0627\u0632.` },\n },\n data: { screen: 'billing', tier: event.payload.tier },\n }),\n});\n```\n\n**(b) Drain the audit stream on a cron.** If you would rather keep everything inside your backend, the\nwatermark-plus-allow-list pattern in [`templates/alerts-to-slack`](../alerts-to-slack) reads the same\nevents with `GET /v1/audit/export?after_id=\u2026` and needs no public endpoint at all. Same events, no\ninbound surface.\n\nWhichever you pick, send on the **`from_tier \u2192 tier` crossing**, not on every event: entitlement snapshots\nare refolded on redelivery, and \"Pro is active\" three times is how an app gets its notifications muted.\n\n## Swapping the push service\n\nThe blueprint targets Expo because it fronts **both** APNs and FCM with one token format. To swap:\n\n| Service | `PUSH_URL` | `egressAllow` | Also change |\n|---|---|---|---|\n| Expo (default) | `https://exp.host/--/api/v2/push/send` | `exp.host`, `api.expo.dev` | \u2014 |\n| FCM (HTTP v1) | the HTTP v1 send method under `https://fcm.googleapis.com/v1/projects/<id>/` \u2014 the exact URL is in `functions/send-push.ts` | `fcm.googleapis.com` | one message per request; an OAuth bearer; the `message.notification` body shape; hash long tokens (see the 256-char cap above) |\n| APNs | `https://api.push.apple.com/3/device/<token>` | `api.push.apple.com` | one request per device; a JWT `authorization`; an `apns-topic` header; the `aps` payload shape |\n\n### If your push service needs a key\n\nAdd the ref to the function and set the value:\n\n```ts\n'send-push': { /* \u2026 */ secrets: ['secret:push_api_key'] }\n```\n```bash\nprintf '%s' \"$KEY\" | vxil secrets set functions/push_api_key\n```\n\nIt resolves per invocation as `envelope.secrets.push_api_key`, so **rotating it needs no redeploy**. One\nwarning: resolution is **fail-closed**. A declared `secret:<name>` ref with no stored value makes every\ninvocation `409 secret_missing` before your code runs \u2014 which is why this blueprint declares no secrets\nat all by default. Declare one only when you will actually set it.\n\n## What to learn from this\n\n- **The hard part of push is ownership, not delivery.** A `unique` field plus one re-own path is the\n whole difference between a registry that stays correct and one that leaks notifications between users.\n- **A verified principal beats a body field, always.** The platform hands the function `end_user.id`; the\n request body is a suggestion.\n- **Delete on the verdict, not on the error.** `DeviceNotRegistered` is the only signal that justifies\n removing a device. Everything else is about the message.\n- **What an end-user cannot do, a server-mode hook can \u2014 on the user's request.** The claim row is the\n request; the `cmsHook` is the authority. No backend of your own is needed for it.\n- **A cron that cannot finish in one tick must remember where it stopped.** Restarting from the top of\n a newest-first list means the oldest rows are never reached.\n- **One function, several front doors.** `triggers: [...]` puts an http door, a hook and a cron on the\n same bundle \u2014 the code that owns a job keeps all of its entry points.\n\n**Pairs with:** [`templates/alerts-to-slack`](../alerts-to-slack) for the audit-stream drain used in\nexample (b).\n",
18036
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Push notifications\" \u2014 DEVICE TOKENS DONE PROPERLY.\n//\n// Sending a push is the easy part: one HTTPS POST to your push service. What\n// actually breaks in production is the REGISTRY \u2014 the mapping from a user to\n// the devices they are currently holding. Tokens rotate, phones get sold, users\n// sign out and a colleague signs in on the same handset, and a token that once\n// belonged to Alice starts delivering Alice's notifications to Bob. That is a\n// privacy incident, not a bug.\n//\n// So this blueprint is a registry with three invariants and a small amount of\n// sending bolted on \u2014 and it is THE supported Expo path on vxil (guide 17):\n// 1. ONE TOKEN, ONE USER \u2014 `token` is `unique`, enforced by the platform.\n// 2. RE-OWN ON CHANGE \u2014 a token that reappears under a different user is\n// REASSIGNED, never duplicated. From your backend (server mode) that is\n// immediate; from the app itself (end-user mode, which cannot see another\n// user's row) it is ASYNC: a claim row + a server-mode cmsHook.\n// 3. PRUNE STALE \u2014 the push service tells you when a token is dead; a cron\n// collects those receipts and deletes the rows, so your registry shrinks.\n// It walks the WHOLE registry over successive ticks (a persisted cursor).\n// Plus the two things every consumer app ends up writing:\n// \u2022 ERASURE \u2014 a user erased under GDPR (auth.user.erased / user.erased) or\n// deleted (user.deleted) loses every device row, claim and preferences\n// row \u2014 never on session.revoked, which fires on every refresh.\n// \u2022 A REMINDER LADDER \u2014 an hourly cron nudging inactive users (1 d, 3 d, 7 d),\n// honouring each user's opt-out and quiet hours in THEIR time zone.\n//\n// \u2022 cms \u2192 `push_tokens` (owner-scoped, `token` unique), `push_token_claims`\n// (an app's async re-own request), `push_prefs` (reminders,\n// time zone, quiet hours, last activity), `push_state` (cursors)\n// \u2022 functions \u2192 `register-token` (the registry: http + the claim cmsHook + the\n// erasure webhooks), `send-push` (http send + the hourly ladder\n// cron), `prune-receipts` (the receipts cron)\n//\n// Three functions and two crons (the fastest hourly) on purpose: that is inside\n// the Free plan's function limits for staging and development projects (guide\n// 12 has the per-plan table), so the whole kit runs on a free project while\n// you build the app. Every tick is bounded and resumable, so a tick the plan\n// sheds is harmless: the next one picks up where the last stopped.\n//\n// Written against Expo's push service because it fronts BOTH APNs and FCM with\n// one token format. Swapping in raw FCM or APNs is a change to two constants\n// and the message body \u2014 see README.md.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Fail-safe: a verified end-user may only touch collections that declare\n // an ownerField. `push_tokens` does, so a signed-in device can read and\n // refresh its OWN rows and structurally cannot enumerate anyone else's.\n strictEndUserScope: true,\n hooks: {\n // A row with no token is a row that can never receive anything.\n token_present: {\n collection: 'push_tokens',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.token) > 0',\n message: 'a push token is required',\n },\n // The ladder walk filters on `ladder_step < 3` (a slot compare, which\n // never matches an absent value), so every prefs row carries one: a\n // write that leaves it out gets 0.\n ladder_step_default: {\n collection: 'push_prefs',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'ladder_step',\n expr: 'coalesce(item.ladder_step, 0)',\n },\n },\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n push_tokens: {\n singular: 'push_token',\n // Owner-scoped: an end-user session sees only its own devices.\n ownerField: 'end_user',\n fields: {\n // \u2605 INVARIANT 1. `unique` is the TOP-LEVEL field attribute (there is no\n // `validation.unique`). vxil enforces it with its uniqueness check, so a\n // second registration of the same token is a 409 `unique_violation` \u2014\n // atomically, under concurrency, without a read-then-write race.\n // NOTE: a unique value is capped at 256 characters. Expo tokens\n // (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside that;\n // if you switch to raw FCM registration tokens, store a hash here and\n // keep the full token in an unindexed `text` field.\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' }, // the owner\n platform: { type: 'string', indexSlot: 's3', validation: { enum: ['ios', 'android', 'web'] } },\n // The device's language, captured at registration \u2014 this is what makes\n // a localized push possible without a second lookup per device.\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'fr', \u2026\n // The receipt id returned by the LAST send. `prune-receipts` trades it\n // for a verdict and deletes the row when the service says the device\n // is gone.\n receipt_id: { type: 'string' },\n // When that receipt id was parked. The push service keeps a receipt\n // for a limited time; past RECEIPT_MAX_AGE the id is cleared as\n // unknowable instead of being asked about forever.\n receipt_at: { type: 'datetime' },\n // Consecutive delivery failures \u2014 a soft signal for your own dashboards\n // (the authoritative kill signal is the receipt, not this counter).\n failures: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_seen: { type: 'datetime', indexSlot: 't1' }, // last registration/refresh\n checked_at: { type: 'datetime', indexSlot: 't2' }, // last receipt check\n last_error: { type: 'text' },\n },\n },\n\n // \u2605 INVARIANT 2, the app's half. An end-user session cannot see \u2014 let\n // alone re-own \u2014 a row that belongs to someone else, so when the app\n // registers a token another user holds, `register-token` files a CLAIM\n // here (owned by the caller) and answers 202. The `register-token`\n // cmsHook binding then runs in SERVER mode, moves the token, and deletes\n // the claim. Usually done within seconds.\n push_token_claims: {\n singular: 'push_token_claim',\n ownerField: 'end_user',\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1' },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3', validation: { enum: ['ios', 'android', 'web'] } },\n locale: { type: 'string', indexSlot: 's4' },\n },\n },\n\n // The reminder ladder's per-user state. Owner-scoped: the app reads and\n // writes its OWN row (opt out, set quiet hours, stamp `last_active` and\n // reset `ladder_step` to 0 whenever the app comes to the foreground).\n push_prefs: {\n singular: 'push_pref',\n ownerField: 'end_user',\n fields: {\n end_user: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // IANA zone ('Asia/Riyadh'); quiet hours are evaluated in it. An\n // unknown zone is treated as UTC.\n timezone: { type: 'string', indexSlot: 's2' },\n // false = no reminders at all (absent = reminders on).\n reminders: { type: 'bool' },\n // Local hours [quiet_start, quiet_end) during which nothing is sent;\n // the window may wrap midnight (22 \u2192 8). Equal values = no quiet hours.\n quiet_start: { type: 'int', validation: { min: 0, max: 23 } },\n quiet_end: { type: 'int', validation: { min: 0, max: 23 } },\n last_active: { type: 'datetime', indexSlot: 't1' },\n // The next rung (0..3) since `last_active` (the app resets it to 0; the\n // `ladder_step_default` derive writes 0 when a write leaves it out).\n ladder_step: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_reminded_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n\n // Where each cron left off (`key` = the job). No ownerField, so with\n // `strictEndUserScope` an end-user session cannot touch it at all.\n push_state: {\n singular: 'push_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // the last item id walked; '' = start again from the newest row\n cursor: { type: 'string' },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n functions: {\n // THE REGISTRY \u2014 one bundle, five front doors (a function is code + a SET\n // of trigger bindings):\n // \u2022 http POST /v1/fn/register-token { token, user_id?, platform?, locale? }\n // server mode (your backend) re-owns at once; end-user mode\n // (the app) files a claim for a token someone else holds \u2192 202.\n // \u2022 cmsHook a new `push_token_claims` row \u2192 re-own it in SERVER mode,\n // delete the claim. `retry` re-delivers a failed attempt.\n // \u2022 webhook `auth.user.erased` (the auth erase), `user.erased` (the\n // admin erase) and `user.deleted` (the admin delete) \u2192 delete\n // every device row, claim and preferences row of that user.\n // NOT `auth.session.revoked`: that fires on every refresh-token\n // rotation, and would sign devices out of push at random.\n 'register-token': {\n entry: './functions/register-token.ts',\n trigger: { kind: 'http' },\n triggers: [\n { kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'auth.user.erased', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'user.erased', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'user.deleted', retry: { maxAttempts: 3 } },\n ],\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // pure vxil-internal\n },\n\n // THE SENDER \u2014 two front doors:\n // \u2022 http POST /v1/fn/send-push { user_ids, messages, data? } \u2014 resolves\n // each user's live devices, picks the message for each device's\n // locale, and posts to the push service in batches.\n // \u2022 cron hourly: THE REMINDER LADDER. Users inactive 1 d / 3 d / 7 d get\n // at most one nudge per step (a user found already a week idle gets\n // only the 7-day one), never during their quiet hours, never after\n // opting out. Each step is claimed (If-Match) BEFORE the send, so\n // an overlapping tick can never send it twice.\n 'send-push': {\n entry: './functions/send-push.ts',\n trigger: { kind: 'http' },\n triggers: [{ kind: 'cron', schedule: '0 * * * *' }],\n scopes: ['cms:read', 'cms:write'],\n // Deny-by-default egress. `exp.host` is Expo's push host; `api.expo.dev`\n // is its newer alias. For raw FCM use 'fcm.googleapis.com'; for APNs,\n // 'api.push.apple.com' (and see README.md on the token-auth swap).\n egressAllow: ['exp.host', 'api.expo.dev'],\n // NO `secrets` here on purpose. Expo's push API needs no credential in its\n // default configuration, and secret resolution is FAIL-CLOSED: a declared\n // `secret:<name>` ref with no stored value makes every invocation\n // `409 secret_missing` before your code runs. Declare a secret only once\n // you will actually set it \u2014 see README.md \xA7\"If your push service needs a key\".\n },\n\n // \u2605 INVARIANT 3. THE GARBAGE COLLECTOR. A push service accepts a send and\n // only later tells you the device was gone \u2014 that verdict arrives as a\n // RECEIPT, not as the send response. Every 6 hours this cron trades parked\n // receipt ids for verdicts and deletes the rows the service declared dead.\n // It resumes where the last tick stopped (`push_state`), so a registry of\n // any size is walked end to end.\n 'prune-receipts': {\n entry: './functions/prune-receipts.ts',\n trigger: { kind: 'cron', schedule: '30 */6 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: ['exp.host', 'api.expo.dev'],\n },\n },\n});\n",
18037
+ "readme": "# Push notifications (mobile)\n\nSending a push is the easy part: one HTTPS POST to your push service. What breaks in production is the\n**registry** \u2014 the mapping from a user to the devices they are currently holding.\n\nTokens rotate. Phones get sold. A user signs out and a colleague signs in on the same handset. Unless\nsomething reassigns that token, the previous owner's notifications keep arriving on someone else's lock\nscreen \u2014 a privacy incident, not a bug. And nobody collects delivery **receipts**, so the registry only\never grows: dead tokens are re-sent to forever and delivery rates look worse than they are.\n\nThis blueprint is that registry, with three invariants, plus the sending, the erasure clean-up and the\nre-engagement ladder every consumer app ends up writing. It is **the supported Expo path on vxil** \u2014\nvxil holds no push credential and runs no push service; your functions call Expo (which fronts APNs and\nFCM) through the egress allowlist. The whole mobile picture is in\n[guide 17 \u2014 vxil for Expo apps](../../docs/guide/17-mobile-apps-with-expo.md).\n\n```bash\nvxil init --template push-notifications\nvxil quickstart --env staging --no-push # or `vxil link <slug> --env staging` for an existing backend\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # four collections + three functions (two of them crons)\n```\n\nThree functions and two crons (the fastest hourly) on purpose: that is inside the Free plan's function\nlimits for staging and development projects (the per-plan table is in guide 12), so a free project runs\nall of it while you build the app. Every cron tick is bounded and resumable, so a tick the plan sheds is\nharmless \u2014 the next one picks up where the last stopped.\n\n> **Plan note.** The three functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n## The three invariants\n\n| # | Invariant | Where it is enforced |\n|---|---|---|\n| **1** | **One token, one user** | `token: { unique: true }` \u2014 the *top-level* field attribute, enforced by vxil's uniqueness check. A second registration of the same token is a `409 unique_violation`, atomically, with no read-then-write race. |\n| **2** | **Re-own on change** | `functions/register-token.ts` \u2014 on that 409 it looks the row up and **PATCHes the owner** with `If-Match`. Reassign, never duplicate. From the app itself (end-user mode) the move is **async**: a claim row + a server-mode `cmsHook` (below). |\n| **3** | **Prune stale** | `functions/prune-receipts.ts` \u2014 a cron every 6 hours trades parked receipt ids for verdicts and **deletes** every row the service reports as `DeviceNotRegistered`. It resumes where the last tick stopped, so the **whole** registry is walked, whatever its size. |\n\nThere is no `validation: { unique: true }` \u2014 that key does not exist and would be silently ignored.\nUniqueness is the top-level `unique` attribute, on scalar field types only.\n\n## The registry\n\n```ts\npush_tokens: {\n ownerField: 'end_user', // a signed-in device sees only its own rows\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3' }, // ios | android | web\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'pt-BR', \u2026\n receipt_id: { type: 'string' }, // the last send's pending verdict\n receipt_at: { type: 'datetime' }, // when it was parked (48 h \u2192 cleared)\n failures: { type: 'int', indexSlot: 'n1' },\n last_seen: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_error: { type: 'text' },\n },\n}\n```\n\nBeside it: `push_token_claims` (the app's async re-own requests, owner-scoped), `push_prefs` (one row per\nuser for the reminder ladder, owner-scoped, `end_user` unique) and `push_state` (each cron's cursor \u2014 no\n`ownerField`, so no end-user session can touch it).\n\n`strictEndUserScope: true` is on, so a verified end-user session may only touch collections that declare\nan `ownerField`. `push_tokens` does \u2014 a device can refresh **its own** rows and structurally cannot\nenumerate anyone else's.\n\n> **The 256-character cap.** A unique value is capped at 256 characters. Expo tokens\n> (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside it. If you switch to raw **FCM\n> registration tokens**, store a hash in `token` and keep the full value in an unindexed `text` field \u2014\n> otherwise long tokens are rejected at write time.\n\n## Registering \u2014 from your backend or from the app\n\n`POST /v1/fn/register-token { token, user_id?, platform?, locale? }`\n\nInvariant 2 needs to reassign a token **away from another user**, which needs a tenant-wide read. A\nfunction invoked with a verified end-user session gets **owner-scoped** callback tokens \u2014 the platform\nstamps the principal onto every scoped token the function mints downstream \u2014 so in end-user mode the\nother user's row is simply invisible. That is the fail-safe working, not a gap. So there are two paths:\n\n| Called from | `user_id` | A token another user holds | Response |\n|---|---|---|---|\n| **your backend** (server key) | from the body | re-owned at once (`If-Match`) | `200 {action:'reowned', previous_owner}` |\n| **the app** (end-user session) | the **verified** session id \u2014 a body `user_id` is ignored | a **claim** row in `push_token_claims`, owned by the caller | `202 {action:'reown_queued', claim_id}` |\n\nEither way a new token is `201 {action:'created'}` and an unchanged owner is `200 {action:'refreshed'}`.\nCalled from the app, the app's thin-client key needs `functions:invoke` (it is allowed on a public key).\n\n**The async re-own.** `register-token` declares a second binding,\n`{ kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } }`.\nA platform-delivered hook carries **no** end-user, so it runs in server mode: it re-reads the claim,\nre-owns the token row for the claimant (or creates it, if the old holder deleted it meanwhile), and\ndeletes the claim. It usually lands within seconds; a failed attempt answers non-2xx and the `retry`\nre-delivers it, and every step is idempotent (a claim that is already gone is done).\n\n**What a claim can and cannot do.** A claim can only name its caller as the new owner (the platform\nstamps the verified owner on the row), and a push token is a bearer credential only the device itself is\nhanded by the OS. Claiming a token you do not hold moves **your** notifications to that device \u2014 no data\nreaches you. The trade-off to know about is availability: a signed-in user who somehow learned another\nuser's Expo token could move that device away from its owner, who then stops receiving pushes (security\nalerts included) until their app starts again and re-registers it. Tokens are not shown to other users\nby anything in this kit, so that needs the token to have leaked. If your app treats pushes as critical,\nadd a `validate` hook on `push_token_claims` that caps open claims, or have the app re-register on every\nforeground (it is idempotent \u2014 `200 {action:'refreshed'}` costs one write).\n\n**Sign-out.** A device that signs out should stop receiving the old user's pushes before anyone else\nsigns in: delete the device's own row from the app (`DELETE /v1/cms/items/push_tokens/{item_id}` with\nthe session \u2014 an owner may delete its own rows), or just let the next sign-in re-own it.\n\n## Erasure and deletion \u2014 `auth.user.erased`, not `session.revoked`\n\n`register-token` also binds `{ kind: 'webhook', source: 'auth.user.erased' }`,\n`{ kind: 'webhook', source: 'user.erased' }` (the admin `DELETE /v1/users/{id}?erase=true`) and\n`{ kind: 'webhook', source: 'user.deleted' }` (the admin delete without erase). That user loses every\n`push_tokens` row, pending claim and their `push_prefs` row (time zone, quiet hours, activity) \u2014 device\ntokens and preferences are personal data, vxil's erasure scrubs its own tables, not your collections,\nand a deleted account must stop receiving reminder nudges.\n\nA **merge or rekey** (`auth.user.merged` / `auth.user.rekeyed` \u2014 a guest becoming a registered user)\nneeds no binding: the app's next start registers its token under the new id, which re-owns the device.\nThe old id's prefs row goes quiet on its own (it has no devices left to nudge).\n\nDo **not** clean up on `auth.session.revoked`: it fires on every refresh-token rotation as well as on\nsign-out, so a cleanup bound to it would unregister devices at random.\n\n## Sending \u2014 localized per device, not per user\n\n`POST /v1/fn/send-push`\n\n```json\n{\n \"user_ids\": [\"usr_a\", \"usr_b\"],\n \"messages\": {\n \"default\": { \"title\": \"You're in\", \"body\": \"Pro is active on this device.\" },\n \"ar\": { \"title\": \"\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644\", \"body\": \"\u0623\u0635\u0628\u062D \u0627\u0634\u062A\u0631\u0627\u0643 Pro \u0645\u0641\u0639\u0651\u0644\u0627\u064B.\" },\n \"fr\": { \"title\": \"C'est actif\", \"body\": \"Pro est activ\xE9 sur cet appareil.\" }\n },\n \"data\": { \"screen\": \"billing\" }\n}\n```\n\nOne person can carry an English phone and an Arabic tablet. Because `locale` lives on the **token row**,\neach device gets the right copy with no extra lookup \u2014 exact match (`pt-BR`), then the base language\n(`pt`), then `default`.\n\nThe response distinguishes the two failure kinds, because they call for opposite actions:\n\n- an **immediate** `DeviceNotRegistered` \u2192 the token is dead now; the row is deleted;\n- anything else (rate limits, a message too big, a transient fault) \u2192 counted in `failures`, device kept.\n Deleting a device because one message was malformed is how a registry loses real users.\n- an **accepted** message returns a *receipt id*, not a delivery. That id is parked on the row, and the\n real verdict is collected by the cron below.\n\n## The receipts cron \u2014 the loop almost nobody closes\n\n`prune-receipts` runs at `30 */6 * * *`. It resumes the walk of `push_tokens` where the previous tick\nstopped (its cursor lives in `push_state`), collects the rows carrying a `receipt_id`, asks the push\nservice for those receipts in batches, and acts \u2014 `ok` clears the id, `DeviceNotRegistered` deletes the\nrow, anything else counts a failure and keeps the device. A receipt that is not ready yet is simply\nabsent from the response, which is not an error; it is asked again on the next walk. A receipt id parked\nfor more than 48 hours with no answer is cleared \u2014 the service no longer keeps it, and asking forever\nwould pin the row.\n\nEverything is bounded: one tick reads at most 2,000 rows and acts on at most 300, so its outbound calls\nstay in the low hundreds. Because the cursor persists, a registry of **any** size is walked end to end\nover successive ticks; when the walk reaches the oldest row the cursor resets and the next tick starts\nagain from the newest (rows registered during a walk are covered by the next one).\n\n> **A paging detail worth stealing.** The cron pages with `?cursor=` and **no** `?sort=`. In the\n> default order (newest first) the cursor is a plain item id, so it stays valid across ticks \u2014 persist it\n> and the walk resumes exactly where it stopped.\n\n**Never clears a newer receipt.** Each clear is a `PATCH` with `If-Match` on the row version the walk\nread. If `send-push` parked a newer receipt id on the same device in between, the clear is refused and\ncounted as `raced`; the newer id is checked on the next walk.\n\n## The reminder ladder \u2014 re-engagement that respects the user\n\n`send-push` has a second binding, `{ kind: 'cron', schedule: '0 * * * *' }`. Every hour it nudges users\nwho stopped opening the app: once after **1 day**, once after **3**, once after **7** \u2014 then silence until\nthey come back. The copy lives in the `LADDER` constant at the top of `functions/send-push.ts` (per\nlocale, picked per device like any send).\n\nIt reads `push_prefs`, a row per user **the app writes itself** (owner-scoped):\n\n| Field | Meaning |\n|---|---|\n| `reminders` | `false` = never. Absent = on. |\n| `timezone` | IANA zone (`Asia/Riyadh`); quiet hours are evaluated in it. Unknown = UTC. |\n| `quiet_start`, `quiet_end` | local hours `[start, end)` with nothing sent; may wrap midnight (`22` \u2192 `8`). Equal = none. |\n| `last_active` | stamp it whenever the app comes to the foreground\u2026 |\n| `ladder_step` | \u2026and reset this to `0` at the same time. The cron advances it. Left out on a write, a `derive` hook stores `0`. |\n\n```ts\n// in the app, on AppState 'active' (end-user session)\nawait vx.from('push_prefs').patch(prefsId, { last_active: new Date().toISOString(), ladder_step: 0 });\n```\n\nThree guarantees:\n\n- **Only idle users are read.** The walk filters on `last_active` and `ladder_step < 3` (both indexed),\n so active users and users who finished the ladder cost nothing; it resumes where the last tick stopped\n (`push_state`), reading at most 2,000 rows and nudging at most 100 users per tick. Users who opted out\n are still read (an absent `reminders` means on, which a filter cannot express) and skipped.\n- **Never twice, never a burst.** Each step is claimed on the prefs row with `If-Match` **before** the\n send \u2014 two overlapping ticks cannot both send it. A send that fails after the claim is a missed nudge,\n never a duplicate one. When several rungs are due at once (the kit added to a live app, reminders\n turned back on, a cron that was held), only the **highest** is sent and the ones below are skipped; and\n a rung waits its own gap after the previous nudge (`last_reminded_at`), so the 3-day and 7-day nudges\n stay two and four days after the one before.\n- **Never at night.** A user inside their quiet hours is skipped and picked up by a later tick once the\n window has passed (the next one, unless the walk spans more than one tick).\n\n## Example: post-purchase push on `payments.entitlement.changed`\n\nThe payments feature writes `payments.entitlement.changed` to your audit stream on every entitlement\ntransition, carrying `end_user_id`, the previous and new tier, `status`, `until`, `reason` and\n`environment`. Turning that into \"your Pro plan is live, in the language of the device\" is a wiring\nexercise, not new code \u2014 there are two shapes, both using the same `send-push` call:\n\n**(a) Subscribe and call in.** Outbound subscriptions are runtime rows, not config:\n\n```bash\ncurl -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H \"authorization: Bearer $VXIL_API_KEY\" -H 'content-type: application/json' \\\n -d '{\"target_url\":\"https://your-app.example.com/hooks/vxil\",\"event_prefixes\":[\"payments.entitlement.\"]}'\n```\n\nYour endpoint verifies the signed delivery, then calls `send-push` with a key holding\n`functions:invoke`:\n\n```ts\n// inside your webhook receiver \u2014 event.payload.end_user_id / .tier are the fields above\nawait fetch('https://api.vxil.com/v1/fn/send-push', {\n method: 'POST',\n headers: { authorization: `Bearer ${process.env.VXIL_API_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_ids: [event.payload.end_user_id],\n messages: {\n default: { title: 'You\\'re in', body: `${event.payload.tier} is active on this device.` },\n ar: { title: '\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644', body: `\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 ${event.payload.tier} \u0639\u0644\u0649 \u0647\u0630\u0627 \u0627\u0644\u062C\u0647\u0627\u0632.` },\n },\n data: { screen: 'billing', tier: event.payload.tier },\n }),\n});\n```\n\n**(b) Drain the audit stream on a cron.** If you would rather keep everything inside your backend, the\nwatermark-plus-allow-list pattern in [`templates/alerts-to-slack`](../alerts-to-slack) reads the same\nevents with `GET /v1/audit/export?after_id=\u2026` and needs no public endpoint at all. Same events, no\ninbound surface.\n\nWhichever you pick, send on the **`from_tier \u2192 tier` crossing**, not on every event: entitlement snapshots\nare refolded on redelivery, and \"Pro is active\" three times is how an app gets its notifications muted.\n\n## Swapping the push service\n\nThe blueprint targets Expo because it fronts **both** APNs and FCM with one token format. To swap:\n\n| Service | `PUSH_URL` | `egressAllow` | Also change |\n|---|---|---|---|\n| Expo (default) | `https://exp.host/--/api/v2/push/send` | `exp.host`, `api.expo.dev` | \u2014 |\n| FCM (HTTP v1) | the HTTP v1 send method under `https://fcm.googleapis.com/v1/projects/<id>/` \u2014 the exact URL is in `functions/send-push.ts` | `fcm.googleapis.com` | one message per request; an OAuth bearer; the `message.notification` body shape; hash long tokens (see the 256-char cap above) |\n| APNs | `https://api.push.apple.com/3/device/<token>` | `api.push.apple.com` | one request per device; a JWT `authorization`; an `apns-topic` header; the `aps` payload shape |\n\n### If your push service needs a key\n\nAdd the ref to the function and set the value:\n\n```ts\n'send-push': { /* \u2026 */ secrets: ['secret:push_api_key'] }\n```\n```bash\nprintf '%s' \"$KEY\" | vxil secrets set functions/push_api_key\n```\n\nIt resolves per invocation as `envelope.secrets.push_api_key`, so **rotating it needs no redeploy**. One\nwarning: resolution is **fail-closed**. A declared `secret:<name>` ref with no stored value makes every\ninvocation `409 secret_missing` before your code runs \u2014 which is why this blueprint declares no secrets\nat all by default. Declare one only when you will actually set it.\n\n## What to learn from this\n\n- **The hard part of push is ownership, not delivery.** A `unique` field plus one re-own path is the\n whole difference between a registry that stays correct and one that leaks notifications between users.\n- **A verified principal beats a body field, always.** The platform hands the function `end_user.id`; the\n request body is a suggestion.\n- **Delete on the verdict, not on the error.** `DeviceNotRegistered` is the only signal that justifies\n removing a device. Everything else is about the message.\n- **What an end-user cannot do, a server-mode hook can \u2014 on the user's request.** The claim row is the\n request; the `cmsHook` is the authority. No backend of your own is needed for it.\n- **A cron that cannot finish in one tick must remember where it stopped.** Restarting from the top of\n a newest-first list means the oldest rows are never reached.\n- **One function, several front doors.** `triggers: [...]` puts an http door, a hook and a cron on the\n same bundle \u2014 the code that owns a job keeps all of its entry points.\n\n**Pairs with:** [`templates/alerts-to-slack`](../alerts-to-slack) for the audit-stream drain used in\nexample (b).\n",
18386
18038
  "functions": {
18387
18039
  "prune-receipts.ts": "// prune-receipts.ts \u2014 THE GARBAGE COLLECTOR (a vxil function, cron trigger).\n//\n// Trigger: cron `30 */6 * * *` (every 6 hours).\n//\n// A push service accepts a message and answers \"ok\" long before it knows whether\n// the device still exists. The real verdict arrives later, as a RECEIPT keyed by\n// the id the send returned. Almost nobody collects them, which is why push\n// registries only ever grow: dead tokens are re-sent to forever, quota is spent\n// on handsets that were wiped a year ago, and delivery rates look worse than\n// they are.\n//\n// This cron closes that loop:\n// 1. resume the walk of `push_tokens` where the previous tick stopped (the\n// cursor lives in `push_state`), collecting rows that carry a `receipt_id`,\n// 2. ask the push service for those receipts, in batches,\n// 3. `DeviceNotRegistered` \u2192 DELETE the row (\u2605 invariant 3),\n// any other error \u2192 count it and keep the device,\n// ok \u2192 clear the receipt id; there is nothing left to check,\n// no receipt and older than RECEIPT_MAX_AGE_H \u2192 clear it (the service no\n// longer has it; asking forever would pin the row),\n// Every clear is a PATCH with If-Match on the row version that was read:\n// if `send-push` parked a NEWER receipt id on the device meanwhile, the\n// clear is refused (409) and the newer id stays for the next walk,\n// 4. save the cursor \u2014 or '' when the walk reached the oldest row, so the\n// next tick starts again from the newest.\n//\n// Everything is bounded: one tick reads at most MAX_ROWS rows and acts on at\n// most MAX_ACTIONS of them, so its outbound calls stay in the low hundreds.\n// Because the cursor persists, a registry of ANY size is walked end to end over\n// successive ticks \u2014 MAX_ROWS \xD7 4 ticks a day. (The walk follows the default\n// newest-first order; rows registered while a walk is in progress are covered\n// by the next walk.)\n\n// cron-walk: persisted-cursor \u2014 the walk's cursor lives in `push_state` and is saved with If-Match.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\nconst RECEIPTS_URL = 'https://exp.host/--/api/v2/push/getReceipts';\n/** Receipt ids per request. */\nconst RECEIPT_BATCH = 300;\n/** Token rows examined per cron tick. */\nconst MAX_ROWS = 2000;\n/** Rows with a pending receipt acted on per tick (each costs one PATCH or DELETE). */\nconst MAX_ACTIONS = 300;\n/** A receipt id parked longer than this is cleared as unknowable. */\nconst RECEIPT_MAX_AGE_H = 48;\n/** cms page size \u2014 100 is the default `maxPageSize` for the cms feature. */\nconst PAGE = 100;\n/** This cron's row in `push_state`. */\nconst STATE_KEY = 'prune-receipts';\n\ntype Env = CronFunctionEnvelope;\ninterface TokenRow {\n item_id: string;\n version?: number;\n data: { token?: string; receipt_id?: string; receipt_at?: string; failures?: number };\n}\ninterface Receipt { status?: string; message?: string; details?: { error?: string } }\ninterface StateRow { item_id: string; version?: number; data: { cursor?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n // \u2500\u2500 1. Resume the walk; collect pending receipts. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const state = await readState(base, H, STATE_KEY);\n if (state === 'error') return Response.json({ error: 'state_read_failed' }, { status: 502 });\n const startedAt = state?.data.cursor || null;\n const pending = new Map<string, TokenRow>(); // receipt_id \u2192 row\n let cursor: string | null = startedAt;\n let scanned = 0;\n let wrapped = false;\n while (scanned < MAX_ROWS && pending.size < MAX_ACTIONS) {\n // NOTE: no `sort=` here on purpose. In the DEFAULT order (newest first)\n // a cursor is a plain item id, which is what we persist between ticks\n // and resume `item_id <` from. Paging beats ordering here.\n const url = `${base}/v1/cms/items/push_tokens?limit=${PAGE}`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : '');\n const res = await fetch(url, { headers: H });\n if (!res.ok) return Response.json({ error: 'list_failed', status: res.status }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: TokenRow[]; next_cursor?: string | null } };\n const items = body.data?.items ?? [];\n let stoppedEarly = false;\n for (const it of items) {\n scanned++;\n if (it.data.receipt_id) pending.set(it.data.receipt_id, it);\n cursor = it.item_id; // resume AFTER the last row actually looked at\n if (pending.size >= MAX_ACTIONS || scanned >= MAX_ROWS) { stoppedEarly = true; break; }\n }\n if (stoppedEarly) break;\n if (!body.data?.next_cursor || items.length === 0) { wrapped = true; break; }\n cursor = body.data.next_cursor;\n }\n // Reached the oldest row \u2192 the next tick starts again from the newest.\n const nextCursor = wrapped ? '' : (cursor ?? '');\n\n // \u2500\u2500 2 + 3. Ask for verdicts and act on them. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const ids = [...pending.keys()];\n let checked = 0;\n let pruned = 0;\n let cleared = 0;\n let expired = 0;\n let raced = 0;\n const errors: string[] = [];\n const now = Date.now();\n\n for (let i = 0; i < ids.length; i += RECEIPT_BATCH) {\n const slice = ids.slice(i, i + RECEIPT_BATCH);\n const res = await fetch(RECEIPTS_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify({ ids: slice }),\n }).catch(() => null);\n if (!res || !res.ok) {\n errors.push(`receipts request returned ${res?.status ?? 'network error'}`);\n continue;\n }\n // Receipts come back keyed by id \u2014 a receipt that is not ready yet is\n // simply ABSENT, which is not an error: it is checked again next walk.\n const map = (((await res.json().catch(() => ({}))) as { data?: Record<string, Receipt> }).data) ?? {};\n for (const id of slice) {\n const receipt = map[id];\n const row = pending.get(id)!;\n if (!receipt) {\n // Not ready \u2014 or no longer kept by the service. Past the age bound\n // the id can never be answered: clear it so the row is not pinned.\n const at = row.data.receipt_at ? Date.parse(row.data.receipt_at) : NaN;\n if (Number.isFinite(at) && now - at > RECEIPT_MAX_AGE_H * 3_600_000) {\n if (await patch(base, H, row, { receipt_id: '', checked_at: iso() })) expired++; else raced++;\n }\n continue;\n }\n checked++;\n if (receipt.status === 'ok') {\n // Delivered. Nothing more to check for this device.\n if (await patch(base, H, row, { receipt_id: '', failures: 0, checked_at: iso() })) cleared++; else raced++;\n continue;\n }\n const reason = receipt.details?.error ?? receipt.message ?? 'unknown';\n if (reason === 'DeviceNotRegistered') {\n // \u2605 INVARIANT 3. The device is gone \u2014 the row goes with it. This is\n // also the ONLY safe automatic delete: every other error is transient\n // or about the message, not about the device's existence.\n await fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, { method: 'DELETE', headers: H })\n .catch(() => null);\n pruned++;\n continue;\n }\n if (!await patch(base, H, row, {\n receipt_id: '',\n failures: (row.data.failures ?? 0) + 1,\n last_error: String(reason).slice(0, 300),\n checked_at: iso(),\n })) raced++;\n errors.push(reason);\n }\n }\n\n // \u2500\u2500 4. Save where the walk stopped (If-Match: a concurrent tick wins). \u2500\u2500\u2500\u2500\n const saved = await writeState(base, H, STATE_KEY, state, nextCursor);\n\n return Response.json({\n scanned, pending: pending.size, checked, pruned, cleared, expired, raced,\n resumed_from: startedAt, cursor: nextCursor, wrapped, saved, errors: errors.slice(0, 10),\n });\n },\n};\n\n/** Clear/record against the version that was READ. false = the row changed\n * meanwhile (409: a newer receipt is parked \u2014 leave it) or the write failed. */\nasync function patch(base: string, H: Record<string, string>, row: TokenRow, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\n/** This cron's `push_state` row, null when it does not exist yet. */\nasync function readState(base: string, H: Record<string, string>, key: string): Promise<StateRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${base}/v1/cms/items/push_state?filter=${filter}&limit=1`, { headers: H });\n if (!res.ok) return 'error';\n return ((await res.json()) as { data?: { items?: StateRow[] } }).data?.items?.[0] ?? null;\n}\n\n/** Persist the cursor. First tick creates the row (a racing tick's 409 on the\n * unique `key` is fine \u2014 that tick saved its own); later ticks PATCH with\n * If-Match so two overlapping ticks cannot both move it. */\nasync function writeState(\n base: string, H: Record<string, string>, key: string, state: StateRow | null, cursor: string,\n): Promise<boolean> {\n const data = { key, cursor, updated_at: iso() };\n const res = state\n ? await fetch(`${base}/v1/cms/items/push_state/${state.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(state.version !== undefined ? { 'if-match': String(state.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null)\n : await fetch(`${base}/v1/cms/items/push_state`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\nconst iso = () => new Date().toISOString();\n",
18388
18040
  "register-token.ts": "// register-token.ts \u2014 THE DEVICE REGISTRY (a vxil function with five bindings).\n//\n// http POST /v1/fn/register-token { token, user_id?, platform?, locale? }\n// cmsHook a new `push_token_claims` row (the app's async re-own request)\n// webhook `auth.user.erased` / `user.erased` / `user.deleted` (forget the user)\n//\n// One bundle, because all three are the same job: keeping \"which user holds\n// which device\" true. The envelope's `trigger` says which door was used.\n//\n// \u2500\u2500 http: register \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Call it every time your app starts and every time the OS hands you a new push\n// token. It is idempotent, and it enforces the two registry invariants:\n//\n// 1. ONE TOKEN, ONE USER. `token` is declared `unique`, so the platform \u2014\n// not this code \u2014 decides who wins a race. Two devices registering the\n// same token at the same instant produce exactly one row; the loser sees\n// 409 `unique_violation`.\n// 2. RE-OWN ON CHANGE. A token that comes back under a DIFFERENT user is\n// REASSIGNED, not duplicated. This is the case that matters: a resold\n// phone, a shared tablet, a sign-out/sign-in on the same handset. Without\n// it, the previous owner's notifications keep arriving on someone else's\n// lock screen.\n//\n// IDENTITY. If the call carries a verified end-user session, `envelope.end_user`\n// is populated by the platform and THAT id wins \u2014 a body field can never\n// impersonate a signed-in user. In server mode (your backend, a server key)\n// there is no verified principal and `user_id` from the body is used.\n//\n// SERVER MODE re-owns immediately (a tenant-wide read finds the row whoever\n// owns it). END-USER MODE cannot see another user's row \u2014 the owner scope is\n// the fail-safe working \u2014 so the app's call files a CLAIM in\n// `push_token_claims` (owned by the caller) and answers 202\n// `{ action: 'reown_queued' }`; the cmsHook below finishes the move.\n//\n// \u2500\u2500 cmsHook: finish an async re-own \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Runs in SERVER mode (a platform-delivered hook carries no end-user), so it can\n// read and move any row: re-read the claim, re-own (or create) the token row for\n// the claimant, delete the claim. At-least-once and re-delivered on failure\n// (`retry`), so every step is idempotent: a claim that is already gone is done.\n//\n// \u2500\u2500 webhook: erasure and deletion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// `auth.user.erased { user_id }`, `user.erased { id }` and `user.deleted { id }`\n// (the admin delete without erase) delete every device row, pending claim and\n// the preferences row of that user \u2014 device tokens, time zone and activity are\n// personal data, and a deleted account must stop getting reminder nudges.\n// Deliberately NOT `auth.session.revoked`: that event fires on every\n// refresh-token rotation, not only on sign-out. A MERGE or REKEY\n// (`auth.user.merged` / `.rekeyed`, guest \u2192 registered) needs nothing here: the\n// app's next start registers its token under the new id, which re-owns it.\n\nimport type { CmsHookFunctionEnvelope, HttpFunctionEnvelope, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype RegisterBody = { token?: string; user_id?: string; platform?: string; locale?: string };\ntype Env =\n | HttpFunctionEnvelope<RegisterBody>\n | CmsHookFunctionEnvelope\n | WebhookFunctionEnvelope<{ user_id?: string; id?: string }>;\ninterface TokenRow { item_id: string; version?: number; data: { token?: string; end_user?: string } }\ninterface ClaimRow { item_id: string; data: { token?: string; end_user?: string; platform?: string; locale?: string } }\n\nconst PLATFORMS = new Set(['ios', 'android', 'web']);\n/** Rows deleted per erasure attempt \u2014 a person has a handful of devices; a\n * bigger set is finished by the next attempt (each deletes the next pages). */\nconst ERASE_MAX = 200;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n if (env.trigger === 'cms-hook') return finishClaim(base, H, env);\n if (env.trigger === 'webhook') return forgetUser(base, H, env);\n return register(base, H, env as HttpFunctionEnvelope<RegisterBody>);\n },\n};\n\n// \u2500\u2500 http \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function register(base: string, H: Record<string, string>, env: HttpFunctionEnvelope<RegisterBody>): Promise<Response> {\n const p = env.payload ?? {};\n const token = typeof p.token === 'string' ? p.token.trim() : '';\n // A VERIFIED principal always beats the body. Never trust a client-supplied\n // user id when the platform has already told you who is calling.\n const endUser = env.end_user?.id;\n const owner = endUser ?? (typeof p.user_id === 'string' ? p.user_id.trim() : '');\n if (!token) return json({ error: 'token is required' }, 400);\n if (!owner) return json({ error: 'user_id is required (or invoke with an end-user session)' }, 400);\n // The unique claim is capped at 256 chars; refuse early with a clear message\n // rather than letting the write fail deep in the stack.\n if (token.length > 256) return json({ error: 'token longer than 256 characters \u2014 store a hash instead' }, 400);\n\n const data = tokenData(token, owner, p.platform, p.locale);\n\n // \u2500\u2500 1. Try to create. The common case is one round-trip. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const created = await fetch(`${base}/v1/cms/items/push_tokens`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n });\n if (created.ok) {\n const body = (await created.json()) as { data?: { item_id?: string } };\n return json({ registered: true, action: 'created', item_id: body.data?.item_id }, 201);\n }\n if (created.status !== 409) return json({ error: 'register_failed', status: created.status }, 502);\n\n // \u2500\u2500 2. 409 \u21D2 the token already exists. Find it and decide. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // In SERVER mode this read is tenant-wide and finds the row whoever owns it.\n // In end-user mode it is owner-scoped, so a row belonging to someone else is\n // invisible \u2014 that is the fail-safe working as designed.\n const row = await findToken(base, H, token);\n if (row === 'error') return json({ error: 'lookup_failed' }, 502);\n if (!row) {\n if (!endUser) {\n // Server mode sees every row, so \"exists but not found\" is a race with a\n // concurrent delete. Retrying the call settles it.\n return json({ registered: false, error: 'conflict', hint: 'retry the call' }, 409);\n }\n // \u2605 The async re-own. File a claim (owned by the caller \u2014 the platform\n // stamps the verified owner); the server-mode cmsHook moves the token.\n const claim = await fetch(`${base}/v1/cms/items/push_token_claims`, {\n method: 'POST', headers: H,\n body: JSON.stringify({ status: 'published', data: claimData(token, owner, p.platform, p.locale) }),\n });\n if (!claim.ok) return json({ error: 'claim_failed', status: claim.status }, 502);\n const c = (await claim.json()) as { data?: { item_id?: string } };\n return json({ registered: false, action: 'reown_queued', claim_id: c.data?.item_id }, 202);\n }\n\n const moved = await reown(base, H, row, data);\n if (!moved.ok) {\n // 409 version_conflict = a concurrent registration already moved it. The\n // registry is still correct; the caller can simply retry.\n return json({ registered: false, error: 'conflict', status: moved.status }, 409);\n }\n const previous = row.data.end_user;\n return json({\n registered: true,\n action: previous && previous !== owner ? 'reowned' : 'refreshed',\n item_id: row.item_id,\n ...(previous && previous !== owner ? { previous_owner: previous } : {}),\n }, 200);\n}\n\n// \u2500\u2500 cmsHook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function finishClaim(base: string, H: Record<string, string>, env: CmsHookFunctionEnvelope): Promise<Response> {\n const { collection, item_id: claimId } = env.payload ?? ({} as CmsHookFunctionEnvelope['payload']);\n // The platform filters on the binding's collection; this guard is belt and braces.\n if (collection !== 'push_token_claims' || !claimId) return json({ skipped: true }, 200);\n\n const got = await fetch(`${base}/v1/cms/items/push_token_claims/${claimId}`, { headers: H });\n if (got.status === 404) return json({ done: true, reason: 'claim already handled' }, 200);\n if (!got.ok) return json({ error: 'claim_read_failed', status: got.status }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n const claim = ((await got.json()) as { data?: ClaimRow }).data;\n const token = claim?.data.token;\n const owner = claim?.data.end_user;\n if (!token || !owner) {\n await deleteItem(base, H, 'push_token_claims', claimId);\n return json({ done: true, reason: 'empty claim dropped' }, 200);\n }\n\n const data = tokenData(token, owner, claim.data.platform, claim.data.locale);\n const row = await findToken(base, H, token);\n if (row === 'error') return json({ error: 'lookup_failed' }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n let action: string;\n if (!row) {\n // The holder deleted it in the meantime: the claimant simply registers it.\n const created = await fetch(`${base}/v1/cms/items/push_tokens`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n });\n // 409 = it was registered again concurrently; the next delivery re-reads.\n if (!created.ok) return json({ error: 'create_failed', status: created.status }, 502);\n action = 'created';\n } else {\n const moved = await reown(base, H, row, data);\n if (!moved.ok) return json({ error: 'reown_failed', status: moved.status }, 502); // another attempt re-reads (maxAttempts: 3)\n action = row.data.end_user === owner ? 'refreshed' : 'reowned';\n }\n await deleteItem(base, H, 'push_token_claims', claimId);\n return json({ done: true, action, claim_id: claimId }, 200);\n}\n\n// \u2500\u2500 webhook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function forgetUser(\n base: string, H: Record<string, string>, env: WebhookFunctionEnvelope<{ user_id?: string; id?: string }>,\n): Promise<Response> {\n const event = env.payload?.event;\n const d = env.payload?.data;\n const fields = d && !('truncated' in d) ? d : null;\n // auth.user.erased carries `user_id`; the admin erase and delete carry `id`.\n const userId = event === 'auth.user.erased' ? fields?.user_id\n : event === 'user.erased' || event === 'user.deleted' ? fields?.id : undefined;\n if (typeof userId !== 'string' || !userId) return json({ skipped: true, event }, 200);\n\n let deleted = 0;\n let failed = 0;\n for (const coll of ['push_tokens', 'push_token_claims', 'push_prefs']) {\n const filter = encodeURIComponent(JSON.stringify({ end_user: userId }));\n while (deleted + failed < ERASE_MAX) {\n // Always the FIRST page: what was deleted is gone, so the next read\n // returns the next rows. A row that refuses to delete ends the loop.\n const res = await fetch(`${base}/v1/cms/items/${coll}?filter=${filter}&limit=50`, { headers: H });\n if (!res.ok) return json({ error: 'list_failed', status: res.status, deleted }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n const items = ((await res.json()) as { data?: { items?: Array<{ item_id: string }> } }).data?.items ?? [];\n if (items.length === 0) break;\n let progress = 0;\n for (const it of items) {\n if (await deleteItem(base, H, coll, it.item_id)) { deleted++; progress++; } else failed++;\n }\n if (progress === 0) break;\n }\n }\n // A failed delete \u2014 or a set larger than one attempt's ERASE_MAX \u2014 answers\n // non-2xx: the binding declares maxAttempts: 3, so the jobs ladder hands it\n // back for another attempt, which deletes the rest.\n const unfinished = failed > 0 || deleted >= ERASE_MAX;\n return json({ user_id: userId, deleted, failed, done: !unfinished }, unfinished ? 502 : 200);\n}\n\n// \u2500\u2500 helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction tokenData(token: string, owner: string, platform: unknown, locale: unknown): Record<string, unknown> {\n return {\n token, end_user: owner, last_seen: new Date().toISOString(), failures: 0, last_error: '',\n ...(typeof platform === 'string' && PLATFORMS.has(platform) ? { platform } : {}),\n ...(typeof locale === 'string' && locale ? { locale: locale.slice(0, 16) } : {}),\n };\n}\n\nfunction claimData(token: string, owner: string, platform: unknown, locale: unknown): Record<string, unknown> {\n return {\n token, end_user: owner,\n ...(typeof platform === 'string' && PLATFORMS.has(platform) ? { platform } : {}),\n ...(typeof locale === 'string' && locale ? { locale: locale.slice(0, 16) } : {}),\n };\n}\n\nasync function findToken(base: string, H: Record<string, string>, token: string): Promise<TokenRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ token }));\n const found = await fetch(`${base}/v1/cms/items/push_tokens?filter=${filter}&limit=1`, { headers: H });\n if (!found.ok) return 'error';\n return ((await found.json()) as { data?: { items?: TokenRow[] } }).data?.items?.[0] ?? null;\n}\n\n/** \u2605 INVARIANT 2: re-own (or just refresh, when the owner is unchanged).\n * If-Match makes the reassignment atomic against a concurrent registration. */\nfunction reown(base: string, H: Record<string, string>, row: TokenRow, data: Record<string, unknown>): Promise<Response> {\n return fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data }),\n });\n}\n\n/** true when the row is gone afterwards (a 404 counts: someone else deleted it). */\nasync function deleteItem(base: string, H: Record<string, string>, coll: string, itemId: string): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/${coll}/${itemId}`, { method: 'DELETE', headers: H }).catch(() => null);\n return !!res && (res.ok || res.status === 404);\n}\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
@@ -18406,8 +18058,8 @@ export default defineConfig({
18406
18058
  "slack_webhook_url",
18407
18059
  "vxil_read_key"
18408
18060
  ],
18409
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Alerts to Slack\" \u2014 failure events \u2192 one chat message per state crossing.\n//\n// There is no vxil \"connectors\" feature and no alert-routing engine, on purpose:\n// every feature already writes its lifecycle to your tenant's audit stream, and\n// a function can read that stream. This blueprint is DISTRIBUTION over that\n// spine \u2014 one cron function, one cms collection as its memory, one BYO webhook:\n// \u2022 cms \u2192 `alert_state`: the watermark + per-condition state (ok|stale|\n// broken|could_not_check) that makes alerts once-per-crossing\n// \u2022 functions \u2192 `alerts`: drains the audit stream past the watermark every\n// 5 minutes, matches an allow-list of failure events, posts\n// to Slack (or Discord) through the egress allowlist\n// A permanently-red condition therefore produces ONE message when it turns red,\n// ONE when it recovers, and (optionally) a reminder every REPEAT_AFTER_HOURS.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {},\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n // The function's memory. One row per alert KEY (e.g. `payments:ingest:stripe`,\n // `jobs:dead-letter:notifications.deliver`, `functions:checkout`) plus the\n // reserved `__cursor__` row that holds the audit-stream watermark.\n alert_state: {\n singular: 'alert_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true }, // unique \u21D2 409 on a racing duplicate\n state: { type: 'string', indexSlot: 's2' }, // ok | stale | broken | could_not_check\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n event: { type: 'string' }, // the last audit event name seen for this key\n detail: { type: 'text' }, // last reason/error text (truncated)\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n last_event_id: { type: 'string' },\n last_alert_at: { type: 'datetime', indexSlot: 't1' },\n updated_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n },\n },\n\n functions: {\n // The whole blueprint is this one function. Cron every 5 minutes; also\n // invocable by hand (`vxil functions invoke alerts --data '{\"test\":true}'`)\n // to prove the webhook is wired without touching the watermark.\n alerts: {\n entry: './functions/alerts.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' },\n scopes: ['cms:read', 'cms:write'],\n // Two BYO secrets, resolved per invocation and injected as env.secrets.<name>:\n // slack_webhook_url \u2014 the Slack incoming-webhook URL (or a Discord webhook,\n // or a Google Chat space webhook)\n // vxil_read_key \u2014 an API key of YOUR tenant holding ONLY `features:read`;\n // the audit stream is not reachable through the\n // function's scoped callback, so the drain reads it with\n // this least-privilege key instead\n secrets: ['secret:slack_webhook_url', 'secret:vxil_read_key'],\n // Deny-by-default egress: only the chat host (add 'discord.com' for Discord,\n // 'chat.googleapis.com' for a Google Chat space webhook).\n egressAllow: ['hooks.slack.com'],\n },\n },\n\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n vxil_read_key: { feature: 'functions', description: 'a vxil API key with only features:read \u2014 reads the audit stream' },\n },\n});\n",
18410
- "readme": "# Alerts to Slack (ops)\n\nFailure events from your backend \u2192 **one Slack (or Discord) message per state crossing**, with a\n\"resolved\" message when the condition clears. No connectors registry, no alert-routing feature \u2014 one\ncron function that drains your tenant's **audit stream** past a watermark, an allow-list of failure\nevents it cares about, and a `cms` collection as its memory so a permanently-red condition never\nspams the channel.\n\n```bash\nvxil init --template alerts-to-slack\nvxil quickstart # or `vxil link <slug>` for an existing backend\nprintf '%s' \"$SLACK_URL\" | vxil secrets set functions/slack_webhook_url\nprintf '%s' \"$READ_KEY\" | vxil secrets set functions/vxil_read_key\nvxil push # collection + the `alerts` cron function\nvxil functions invoke alerts --data '{\"test\":true}' # posts a test message\n```\n\n**The two secrets** (references in the config, values only ever in the encrypted secret store):\n\n| Secret | What it is | Where it comes from |\n|---|---|---|\n| `slack_webhook_url` | a Slack *incoming webhook* URL (or a Discord webhook URL, or a Google Chat *space webhook* URL) | Slack \u2192 Apps \u2192 Incoming Webhooks; Discord \u2192 channel \u2192 Integrations \u2192 Webhooks; Google Chat \u2192 space \u2192 Apps & integrations \u2192 Webhooks |\n| `vxil_read_key` | an API key **of this tenant** holding **only `features:read`** | dashboard \u2192 API keys \u2192 create, tick `features:read` and nothing else |\n\nWhy a key at all? The audit stream (`GET /v1/audit` and its NDJSON export) is a control-plane read that is\n**not** on a function's scoped callback (the callback covers the feature APIs \u2014 cms, payments, jobs\u2026),\nso the drain reads it with the narrowest key that can: read-only, no write scope, revocable in one click.\nFor Discord, add `'discord.com'` to `egressAllow` \u2014 the function sends both Slack's `text` and Discord's\n`content` field, so one message body works on either.\nFor Google Chat, add `'chat.googleapis.com'` to `egressAllow` \u2014 a space webhook takes `text` alone, and the\nfunction sends only that field to that host.\n\n## What it does, every 5 minutes\n\n1. **Watermark.** Reads the `__cursor__` row in `alert_state`. On the very first run it stores the\n *newest* audit id and stops \u2014 history never becomes an alert storm.\n2. **Drain.** `GET /v1/audit/export?after_id=<cursor>&limit=500` (ascending NDJSON), up to 5 pages a tick.\n3. **Match.** Each row is looked up in `RULES` (the allow-list in `functions/alerts.ts`). Anything not\n listed is ignored. A match becomes `(key, state, level)` \u2014 e.g. a `payments.webhook_event.failed`\n row with `provider: stripe, state: broken` \u2192 key `payments:ingest:stripe`, state `broken`.\n4. **Compare + decide.** One `alert_state` row per key. Only a **crossing** posts:\n - not-ok while the row said ok (or no row yet) \u2192 \u{1F534}/\u{1F7E0} alert\n - ok while the row said not-ok \u2192 \u{1F7E2} \"RESOLVED\"\n - same state again \u2192 silence (a \u23F0 reminder after `REPEAT_AFTER_HOURS`, default 24; `0` disables)\n5. **Advance** the watermark with `If-Match` on the cursor row's version. Cron deliveries are\n at-least-once; if two ticks overlap, the first to move the watermark wins, and the `unique` key plus\n `If-Match` on every state row mean a racing run gets a 409 and stands down instead of double-posting.\n\n## The events it listens for\n\nEvery name in the table below is an audit event a vxil feature writes **today**. Keys are what collapse\nrepeats: ten dead letters of the same job are one key, one row, one alert.\n\n| Audit event | Level | Key (one row each) | State |\n|---|---|---|---|\n| `payments.webhook_event.failed` | from payload (`error`) | `payments:ingest:<provider>` | from payload (`broken`) |\n| `payments.webhook.rejected` | from payload (`warn`) | `payments:webhook-rejected:<provider>` | from payload (`broken`) |\n| `payments.grant.failed` | from payload (`error`) | `payments:grant:<credit_type>:<source>` | from payload (`broken`) |\n| `payments.subscription.past_due` | warn | `payments:subscription:<end_user_id>` | broken |\n| `payments.subscription.active` | info | `payments:subscription:<end_user_id>` | **ok** \u2192 posts RESOLVED |\n| `payments.charge.disputed` | warn | `payments:disputes:<provider>` | broken |\n| `job.dead_lettered` | error | `jobs:dead-letter:<job_name>` | broken |\n| `job.dead_letter_quota_exceeded` | error | `jobs:dead-letter-quota` | broken |\n| `job.generation.failed` | error | `jobs:generation:<error_class>` | broken |\n| `functions.quarantined` | error | `functions:<name>` | broken |\n| `functions.quarantine.cleared` | info | `functions:<name>` | **ok** \u2192 posts RESOLVED |\n| `functions.deploy.denied` | warn | `functions:deploy-denied` | broken |\n| `notifications.delivery.dead_lettered` | from payload (`error`) | `notifications:dead-letter:<template_id>` | from payload (`broken`) |\n| `webhooks.delivery.dead_lettered` | from payload (`error`) | `webhooks:delivery:<subscription_id>` | from payload (`broken`) |\n| `webhooks.delivery.recovered` | from payload (`info`) | `webhooks:delivery:<subscription_id>` | **ok** \u2192 posts RESOLVED |\n| `webhooks.inbound.rejected` | from payload (`warn`) | `webhooks:inbound:<source_id>:<reason>` | from payload (`broken`) |\n| `jobs.schedule.missed` | from payload (`warn`) | `jobs:schedule:<schedule_id>` | from payload (**`stale`** \u2014 late, not broken) |\n| `jobs.schedule.recovered` | from payload (`info`) | `jobs:schedule:<schedule_id>` | **ok** \u2192 posts RESOLVED |\n| `functions.run.failed` | from payload (`error`) | `functions:run:<name>` | from payload (`broken`) |\n| `functions.run.recovered` | from payload (`info`) | `functions:run:<name>` | **ok** \u2192 posts RESOLVED |\n\n**Levels differ by event, and the table respects that.** The payments failure events and every\nlifecycle failure event (the last nine rows) carry their own `level` and `state` in the payload, so those\nrules defer to the event. The older `job.*` and `functions.quarantine*` events supply both from the rule.\n\n**Five pairs close their own alerts.** `past_due` \u2192 `active`, `quarantined` \u2192 `quarantine.cleared`,\n`webhooks.delivery.dead_lettered` \u2192 `.recovered`, `jobs.schedule.missed` \u2192 `.recovered` and\n`functions.run.failed` \u2192 `.recovered` share a key, so a recovery posts RESOLVED with no extra rule. Events with no natural \"ok\" \u2014 a dead\nletter \u2014 stay red until the reminder window, which is what `REPEAT_AFTER_HOURS` is for.\n\n**The same failure can arrive twice, on purpose.** Notification sends and outbound webhook deliveries\nride the jobs substrate, so a dead letter also arrives as `job.dead_lettered` with `job_name` set to\n`notifications.deliver` / `webhooks.deliver`. The dedicated `*.dead_lettered` events carry the\nfeature-level context (template id, subscription id, target host) that the generic job row lacks; keep\nboth rules, or drop `job.dead_lettered` if you only want the feature-level view.\n\n**Own the table.** `RULES` is data: add an event name, decide its key and level, push.\n\n## The message\n\n```\n\u{1F534} [ERROR] payments ingest (stripe) \u2192 BROKEN \u2014 signature verification failed (at 2026-09-10T06:00:12Z)\n\u{1F534} [ERROR] job dead-lettered: notifications.deliver \u2192 BROKEN (at 2026-09-10T06:03:44Z)\n\u23F0 STILL BROKEN: function quarantined: checkout (since 2026-09-09T06:01:02Z)\n\u{1F7E2} RESOLVED: payments ingest (stripe) is back to ok (was broken)\n```\n\n## What to learn from this\n\n- **The audit stream is the event spine.** Every feature writes its lifecycle there; a webhook\n subscription fans it out, and a function can drain it. Nothing here needed a new primitive.\n- **Once-per-crossing suppression needs memory \u2014 a cms collection is that memory.** `unique` on `key`\n and `If-Match` on every write make it correct under the at-least-once cron, not just usually right.\n- **Least privilege has a shape.** The function talks to cms through its scoped callback, reads the\n audit stream with a `features:read`-only key it holds as a secret, and can reach exactly one external\n host. Rotate either secret without a redeploy \u2014 refs resolve per invocation.\n\n**Latency:** a failure is posted within one cron interval (5 minutes) of the audit row landing.\n**Pairs with:** `templates/payments-heartbeat/` (the silence detector that emits its own crossings) and\n`templates/push-notifications/`.\n",
18061
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Alerts to Slack\" \u2014 failure events \u2192 one chat message per state crossing.\n//\n// There is no vxil \"connectors\" feature and no alert-routing engine, on purpose:\n// every feature already writes its lifecycle to your tenant's audit stream, and\n// a function can read that stream. This blueprint is DISTRIBUTION over that\n// spine \u2014 one cron function, one cms collection as its memory, one BYO webhook:\n// \u2022 cms \u2192 `alert_state`: the watermark + per-condition state (ok|stale|\n// broken|could_not_check) that makes alerts once-per-crossing\n// \u2022 functions \u2192 `alerts`: drains the audit stream past the watermark every\n// 5 minutes, matches an allow-list of failure events, posts\n// to Slack (or Discord) through the egress allowlist\n// A permanently-red condition therefore produces ONE message when it turns red,\n// ONE when it recovers, and (optionally) a reminder every REPEAT_AFTER_HOURS.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {},\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n // The function's memory. One row per alert KEY (e.g. `payments:ingest:stripe`,\n // `jobs:dead-letter:notifications.deliver`, `functions:checkout`) plus the\n // reserved `__cursor__` row that holds the audit-stream watermark.\n alert_state: {\n singular: 'alert_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true }, // unique \u21D2 409 on a racing duplicate\n state: { type: 'string', indexSlot: 's2' }, // ok | stale | broken | could_not_check\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n event: { type: 'string' }, // the last audit event name seen for this key\n detail: { type: 'text' }, // last reason/error text (truncated)\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n last_event_id: { type: 'string' },\n last_alert_at: { type: 'datetime', indexSlot: 't1' },\n updated_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n },\n },\n\n functions: {\n // The whole blueprint is this one function. Cron every 5 minutes; also\n // invocable by hand (`vxil functions invoke alerts --data '{\"test\":true}'`)\n // to prove the webhook is wired without touching the watermark.\n alerts: {\n entry: './functions/alerts.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free plan: '*/15 * * * *' (its fastest function cron)\n scopes: ['cms:read', 'cms:write'],\n // Two BYO secrets, resolved per invocation and injected as env.secrets.<name>:\n // slack_webhook_url \u2014 the Slack incoming-webhook URL (or a Discord webhook,\n // or a Google Chat space webhook)\n // vxil_read_key \u2014 an API key of YOUR tenant holding ONLY `features:read`;\n // the audit stream is not reachable through the\n // function's scoped callback, so the drain reads it with\n // this least-privilege key instead\n secrets: ['secret:slack_webhook_url', 'secret:vxil_read_key'],\n // Deny-by-default egress: only the chat host (add 'discord.com' for Discord,\n // 'chat.googleapis.com' for a Google Chat space webhook).\n egressAllow: ['hooks.slack.com'],\n },\n },\n\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n vxil_read_key: { feature: 'functions', description: 'a vxil API key with only features:read \u2014 reads the audit stream' },\n },\n});\n",
18062
+ "readme": "# Alerts to Slack (ops)\n\nFailure events from your backend \u2192 **one Slack (or Discord) message per state crossing**, with a\n\"resolved\" message when the condition clears. No connectors registry, no alert-routing feature \u2014 one\ncron function that drains your tenant's **audit stream** past a watermark, an allow-list of failure\nevents it cares about, and a `cms` collection as its memory so a permanently-red condition never\nspams the channel.\n\n```bash\nvxil init --template alerts-to-slack\nvxil quickstart --env staging --no-push # or `vxil link <slug> --env staging` for an existing backend\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nprintf '%s' \"$SLACK_URL\" | vxil secrets set functions/slack_webhook_url\nprintf '%s' \"$READ_KEY\" | vxil secrets set functions/vxil_read_key\nvxil push # collection + the `alerts` cron function\nvxil functions invoke alerts --data '{\"test\":true}' # posts a test message\n```\n\n> **On the Free plan** (staging or development projects) the fastest function cron is every 15 minutes:\n> set `schedule: '*/15 * * * *'` in `vxil.config.ts` before `vxil push`, or the deploy answers\n> `402 plan_limit` (`cron_interval`). Paid plans run the 5-minute schedule as shipped.\n\n> **Plan note.** The `alerts` function deploys on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n**The two secrets** (references in the config, values only ever in the encrypted secret store):\n\n| Secret | What it is | Where it comes from |\n|---|---|---|\n| `slack_webhook_url` | a Slack *incoming webhook* URL (or a Discord webhook URL, or a Google Chat *space webhook* URL) | Slack \u2192 Apps \u2192 Incoming Webhooks; Discord \u2192 channel \u2192 Integrations \u2192 Webhooks; Google Chat \u2192 space \u2192 Apps & integrations \u2192 Webhooks |\n| `vxil_read_key` | an API key **of this tenant** holding **only `features:read`** | dashboard \u2192 API keys \u2192 create, tick `features:read` and nothing else |\n\nWhy a key at all? The audit stream (`GET /v1/audit` and its NDJSON export) is a control-plane read that is\n**not** on a function's scoped callback (the callback covers the feature APIs \u2014 cms, payments, jobs\u2026),\nso the drain reads it with the narrowest key that can: read-only, no write scope, revocable in one click.\nFor Discord, add `'discord.com'` to `egressAllow` \u2014 the function sends both Slack's `text` and Discord's\n`content` field, so one message body works on either.\nFor Google Chat, add `'chat.googleapis.com'` to `egressAllow` \u2014 a space webhook takes `text` alone, and the\nfunction sends only that field to that host.\n\n## What it does, every 5 minutes\n\n1. **Watermark.** Reads the `__cursor__` row in `alert_state`. On the very first run it stores the\n *newest* audit id and stops \u2014 history never becomes an alert storm.\n2. **Drain.** `GET /v1/audit/export?after_id=<cursor>&limit=500` (ascending NDJSON), up to 5 pages a tick.\n3. **Match.** Each row is looked up in `RULES` (the allow-list in `functions/alerts.ts`). Anything not\n listed is ignored. A match becomes `(key, state, level)` \u2014 e.g. a `payments.webhook_event.failed`\n row with `provider: stripe, state: broken` \u2192 key `payments:ingest:stripe`, state `broken`.\n4. **Compare + decide.** One `alert_state` row per key. Only a **crossing** posts:\n - not-ok while the row said ok (or no row yet) \u2192 \u{1F534}/\u{1F7E0} alert\n - ok while the row said not-ok \u2192 \u{1F7E2} \"RESOLVED\"\n - same state again \u2192 silence (a \u23F0 reminder after `REPEAT_AFTER_HOURS`, default 24; `0` disables)\n5. **Advance** the watermark with `If-Match` on the cursor row's version. Cron deliveries are\n at-least-once; if two ticks overlap, the first to move the watermark wins, and the `unique` key plus\n `If-Match` on every state row mean a racing run gets a 409 and stands down instead of double-posting.\n\n## The events it listens for\n\nEvery name in the table below is an audit event a vxil feature writes **today**. Keys are what collapse\nrepeats: ten dead letters of the same job are one key, one row, one alert.\n\n| Audit event | Level | Key (one row each) | State |\n|---|---|---|---|\n| `payments.webhook_event.failed` | from payload (`error`) | `payments:ingest:<provider>` | from payload (`broken`) |\n| `payments.webhook.rejected` | from payload (`warn`) | `payments:webhook-rejected:<provider>` | from payload (`broken`) |\n| `payments.grant.failed` | from payload (`error`) | `payments:grant:<credit_type>:<source>` | from payload (`broken`) |\n| `payments.subscription.past_due` | warn | `payments:subscription:<end_user_id>` | broken |\n| `payments.subscription.active` | info | `payments:subscription:<end_user_id>` | **ok** \u2192 posts RESOLVED |\n| `payments.charge.disputed` | warn | `payments:disputes:<provider>` | broken |\n| `job.dead_lettered` | error | `jobs:dead-letter:<job_name>` | broken |\n| `job.dead_letter_quota_exceeded` | error | `jobs:dead-letter-quota` | broken |\n| `job.generation.failed` | error | `jobs:generation:<error_class>` | broken |\n| `functions.quarantined` | error | `functions:<name>` | broken |\n| `functions.quarantine.cleared` | info | `functions:<name>` | **ok** \u2192 posts RESOLVED |\n| `functions.deploy.denied` | warn | `functions:deploy-denied` | broken |\n| `notifications.delivery.dead_lettered` | from payload (`error`) | `notifications:dead-letter:<template_id>` | from payload (`broken`) |\n| `webhooks.delivery.dead_lettered` | from payload (`error`) | `webhooks:delivery:<subscription_id>` | from payload (`broken`) |\n| `webhooks.delivery.recovered` | from payload (`info`) | `webhooks:delivery:<subscription_id>` | **ok** \u2192 posts RESOLVED |\n| `webhooks.inbound.rejected` | from payload (`warn`) | `webhooks:inbound:<source_id>:<reason>` | from payload (`broken`) |\n| `jobs.schedule.missed` | from payload (`warn`) | `jobs:schedule:<schedule_id>` | from payload (**`stale`** \u2014 late, not broken) |\n| `jobs.schedule.recovered` | from payload (`info`) | `jobs:schedule:<schedule_id>` | **ok** \u2192 posts RESOLVED |\n| `functions.run.failed` | from payload (`error`) | `functions:run:<name>` | from payload (`broken`) |\n| `functions.run.recovered` | from payload (`info`) | `functions:run:<name>` | **ok** \u2192 posts RESOLVED |\n\n**Levels differ by event, and the table respects that.** The payments failure events and every\nlifecycle failure event (the last nine rows) carry their own `level` and `state` in the payload, so those\nrules defer to the event. The older `job.*` and `functions.quarantine*` events supply both from the rule.\n\n**Five pairs close their own alerts.** `past_due` \u2192 `active`, `quarantined` \u2192 `quarantine.cleared`,\n`webhooks.delivery.dead_lettered` \u2192 `.recovered`, `jobs.schedule.missed` \u2192 `.recovered` and\n`functions.run.failed` \u2192 `.recovered` share a key, so a recovery posts RESOLVED with no extra rule. Events with no natural \"ok\" \u2014 a dead\nletter \u2014 stay red until the reminder window, which is what `REPEAT_AFTER_HOURS` is for.\n\n**The same failure can arrive twice, on purpose.** Notification sends and outbound webhook deliveries\nride the jobs substrate, so a dead letter also arrives as `job.dead_lettered` with `job_name` set to\n`notifications.deliver` / `webhooks.deliver`. The dedicated `*.dead_lettered` events carry the\nfeature-level context (template id, subscription id, target host) that the generic job row lacks; keep\nboth rules, or drop `job.dead_lettered` if you only want the feature-level view.\n\n**Own the table.** `RULES` is data: add an event name, decide its key and level, push.\n\n## The message\n\n```\n\u{1F534} [ERROR] payments ingest (stripe) \u2192 BROKEN \u2014 signature verification failed (at 2026-09-10T06:00:12Z)\n\u{1F534} [ERROR] job dead-lettered: notifications.deliver \u2192 BROKEN (at 2026-09-10T06:03:44Z)\n\u23F0 STILL BROKEN: function quarantined: checkout (since 2026-09-09T06:01:02Z)\n\u{1F7E2} RESOLVED: payments ingest (stripe) is back to ok (was broken)\n```\n\n## What to learn from this\n\n- **The audit stream is the event spine.** Every feature writes its lifecycle there; a webhook\n subscription fans it out, and a function can drain it. Nothing here needed a new primitive.\n- **Once-per-crossing suppression needs memory \u2014 a cms collection is that memory.** `unique` on `key`\n and `If-Match` on every write make it correct under the at-least-once cron, not just usually right.\n- **Least privilege has a shape.** The function talks to cms through its scoped callback, reads the\n audit stream with a `features:read`-only key it holds as a secret, and can reach exactly one external\n host. Rotate either secret without a redeploy \u2014 refs resolve per invocation.\n\n**Latency:** a failure is posted within one cron interval (5 minutes; 15 on the Free plan) of the audit row landing.\n**Pairs with:** `templates/payments-heartbeat/` (the silence detector that emits its own crossings) and\n`templates/push-notifications/`.\n",
18411
18063
  "functions": {
18412
18064
  "alerts.ts": "// alerts.ts \u2014 FAILURE EVENTS \u2192 ONE CHAT MESSAGE PER STATE CROSSING (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Each run:\n// 1. reads the watermark (the `__cursor__` row in `alert_state`; first run = \"now\",\n// so history never produces an alert storm),\n// 2. drains the tenant audit stream past it \u2014 `GET /v1/audit/export?after_id=\u2026`\n// (ascending, NDJSON) with the `vxil_read_key` secret (a key holding ONLY\n// `features:read`; the audit stream is not on the scoped callback),\n// 3. matches each row against RULES (the allow-list below) and turns it into a\n// (key, state, level) \u2014 e.g. `payments:ingest:stripe` \u2192 `broken`,\n// 4. compares with the stored state for that key (cms, one row per key, `unique`\n// key \u21D2 a racing run gets a 409 and stands down) and posts ONLY on a crossing:\n// ok\u2192red = alert, red\u2192ok = \"resolved\", red\u2192red = silence (or a reminder after\n// REPEAT_AFTER_HOURS),\n// 5. advances the watermark (If-Match on the cursor row's version \u2014 a concurrent\n// run that already advanced it wins; jobs may deliver a cron tick twice).\n//\n// Manual check: `vxil functions invoke alerts --data '{\"test\":true}'` posts a test\n// message and touches nothing else.\n//\n// Tune the constants; own the RULES table \u2014 it is data, not a routing engine.\n\n// cron-walk: persisted-cursor \u2014 the audit watermark lives in the `__cursor__` row and advances with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst REPEAT_AFTER_HOURS = 24; // remind about a still-red condition this often; 0 = never\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 audit rows \u2014 bounds one cron tick\nconst MAX_POSTS_PER_RUN = 20; // a burst of distinct failures collapses into one summary line\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\ntype Payload = Record<string, unknown>;\n\ninterface Rule {\n /** A literal level, or 'payload' to take `payload.level` (the payments failure\n * events carry one; the jobs/functions ones do not). */\n level: Level | 'payload';\n key: (p: Payload) => string; // one state row per key \u2014 this is what suppresses repeats\n title: (p: Payload) => string;\n /** A literal state, or omitted to take `payload.state` (falling back to 'broken'). */\n state?: State;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\n\n/** THE ALLOW-LIST. Every name below is an audit event a vxil feature writes;\n * anything not listed is ignored by the drain. The KEY is what collapses\n * repeats: ten dead letters of the same job are one key, one row, one alert.\n *\n * Every name below is emitted today (2026-09-10). A rule for an event nobody\n * emits would simply never match \u2014 so if you add one, verify the emitter first.\n */\nconst RULES: Record<string, Rule> = {\n // \u2500\u2500 payments \u2014 the money path. A silent failure here is lost revenue. \u2500\u2500\u2500\u2500\u2500\u2500\n // These three carry `level` (info|warn|error) and `state` (ok|stale|broken|\n // could_not_check) in their payload, so the rule defers to the event.\n 'payments.webhook_event.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:ingest:${str(p.provider, 'all')}`,\n title: (p) => `payments ingest (${str(p.provider, 'all providers')})`,\n },\n 'payments.webhook.rejected': {\n level: 'payload', // emitted as warn/broken\n key: (p) => `payments:webhook-rejected:${str(p.provider)}`,\n title: (p) => `payments webhook rejected (${str(p.provider)})`,\n },\n // NOTE: grant.failed carries NO `provider` \u2014 it is keyed on the credit type\n // and the grant source instead (end_user_id/credit_type/amount/grant_key/\n // source/error). Keying on a field an event does not carry would collapse\n // every provider into one bucket named \"unknown\".\n 'payments.grant.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:grant:${str(p.credit_type, 'credits')}:${str(p.source, 'webhook')}`,\n title: (p) => `entitlement grant failed (${str(p.credit_type, 'credits')} via ${str(p.source, 'webhook')})`,\n },\n // Dunning. `past_due` is the red crossing and `active` is its RESOLVED twin \u2014\n // the same key, so a recovered subscription closes its own alert. Both are\n // emitted at level info, so the rule forces the level it wants.\n 'payments.subscription.past_due': {\n level: 'warn',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'broken',\n },\n 'payments.subscription.active': {\n level: 'info',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'ok',\n },\n // A dispute is a chargeback in progress \u2014 money already collected, now at risk.\n 'payments.charge.disputed': {\n level: 'warn',\n key: (p) => `payments:disputes:${str(p.provider)}`,\n title: (p) => `charge disputed (${str(p.provider)})`,\n state: 'broken',\n },\n\n // \u2500\u2500 jobs \u2014 the delivery substrate. Notification and outbound-webhook sends\n // ride jobs, so THEIR dead letters arrive here too, under `job_name`\n // ('notifications.deliver', 'webhooks.deliver', 'fn-cron:<name>', \u2026).\n // These payloads carry run_id/attempt/job_name and NO level or state, so\n // the rule supplies both. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n title: (p) => `job dead-lettered: ${str(p.job_name)}`,\n state: 'broken',\n },\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n title: () => 'daily dead-letter quota exceeded',\n state: 'broken',\n },\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'failed')}`,\n title: (p) => `generation job failed (${str(p.error_class, 'unclassified')})`,\n state: 'broken',\n },\n\n // \u2500\u2500 functions \u2014 the breaker. Consecutive failures quarantine a function;\n // clearing the quarantine is the ok crossing on the SAME key, so this pair\n // produces exactly one \u{1F534} and one \u{1F7E2}. Payload: name (+ consecutive_failures).\n 'functions.quarantined': {\n level: 'error',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'broken',\n },\n 'functions.quarantine.cleared': {\n level: 'info',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'ok',\n },\n 'functions.deploy.denied': {\n level: 'warn',\n key: () => 'functions:deploy-denied',\n title: (p) => `function deploy denied (needs the ${str(p.required_tier)} tier)`,\n state: 'broken',\n },\n\n // \u2500\u2500 The lifecycle failure events (live since 2026-09-10). Every one of these\n // carries `level` + `state` in its payload (the platform's failure-event\n // vocabulary gate enforces it), so the rules defer to the event. Each\n // failure/recovery pair shares a key, so a recovery posts RESOLVED. \u2500\u2500\u2500\u2500\u2500\u2500\n 'notifications.delivery.dead_lettered': {\n level: 'payload', // emitted as error/broken; payload: delivery_id, template_id, error_class, attempts\n key: (p) => `notifications:dead-letter:${str(p.template_id, 'all')}`,\n title: (p) => `notification delivery dead-lettered (${str(p.template_id, 'all templates')})`,\n },\n 'webhooks.delivery.dead_lettered': {\n level: 'payload', // error/broken; payload: subscription_id, target_host, attempts, error_class\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.delivery.recovered': {\n level: 'payload', // info/ok \u2014 same key as the dead-letter \u2192 RESOLVED\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.inbound.rejected': {\n level: 'payload', // warn/broken; payload: source_id, provider, reason, fingerprint (never the body)\n key: (p) => `webhooks:inbound:${str(p.source_id)}:${str(p.reason)}`,\n title: (p) => `inbound webhook rejected (${str(p.provider, 'source')} ${str(p.source_id)}: ${str(p.reason)})`,\n },\n 'jobs.schedule.missed': {\n level: 'payload', // warn/STALE \u2014 a late tick is stale, not broken; payload: schedule_id, job_name, late_seconds\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n 'jobs.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED on the next on-time fire\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n // A function's cron trigger that produced NO run for 2\xD7 its interval \u2014 the\n // platform's silent-schedule watch (error/stale), and the recovery on the same\n // key. Payload: function, schedule_id, cron, last_run_at, expected_by.\n 'functions.schedule.missed': {\n level: 'payload',\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))} (last run ${str(p.last_run_at, 'never')})`,\n },\n 'functions.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED when a run appears again\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))}`,\n },\n 'functions.run.failed': {\n level: 'payload', // error/broken; payload: name, trigger, run_id, error_class\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n 'functions.run.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n};\n\n// the schedule tick, or a hand invoke (`POST /v1/fn/alerts`) carrying `{ test: true }`\ntype Envelope = CronFunctionEnvelope | HttpFunctionEnvelope<{ test?: boolean }>;\ninterface AuditRow { id: string | number; event: string; payload?: Payload; created_at?: string }\ninterface StateData {\n key: string; state?: State; level?: Level; event?: string; detail?: string;\n cursor?: string; last_event_id?: string; last_alert_at?: string; updated_at?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n const readKey = env.secrets?.vxil_read_key;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n if (!hook || !readKey) return Response.json({ error: 'missing secrets' }, { status: 409 });\n\n // Manual wiring check \u2014 posts, touches nothing else.\n if (env.payload?.test) {\n const r = await post(hook, `\u{1F7E2} [INFO] alerts-to-slack is wired (test message)`);\n return Response.json({ test: true, posted: r.ok, status: r.status });\n }\n\n const store = new Store(base, cms);\n\n // 1. the watermark (first run: start at the newest audit id \u2014 no history storm)\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const head = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: head, updated_at: iso() });\n return Response.json({ initialized: true, cursor: head });\n }\n let cursor = cursorRow.data.cursor ?? '0';\n const cursorVersion = cursorRow.version;\n\n // 2\u20134. drain + evaluate\n const posts: string[] = [];\n let scanned = 0;\n let matched = 0;\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows, next } = await exportAudit(base, readKey, cursor);\n for (const row of rows) {\n scanned++;\n const rule = RULES[row.event];\n if (rule) {\n matched++;\n const msg = await evaluate(store, rule, row);\n if (msg) posts.push(msg);\n }\n cursor = String(row.id);\n }\n if (!next) break;\n cursor = next;\n }\n\n // deliver (bounded)\n let posted = 0;\n const lines = posts.slice(0, MAX_POSTS_PER_RUN);\n if (posts.length > MAX_POSTS_PER_RUN) lines.push(`\u2026 and ${posts.length - MAX_POSTS_PER_RUN} more state changes this tick`);\n for (const text of lines) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n\n // 5. advance the watermark \u2014 If-Match: a concurrent run that already moved it wins\n const advanced = await store.patch(cursorRow.item_id, cursorVersion, { cursor, updated_at: iso() });\n\n return Response.json({ scanned, matched, alerts: posts.length, posted, cursor, advanced });\n },\n};\n\n/** Decide, for one matched audit row, whether a message is due; persist the state. */\nasync function evaluate(store: Store, rule: Rule, row: AuditRow): Promise<string | null> {\n const p = row.payload ?? {};\n const key = rule.key(p);\n const next: State = rule.state ?? (isState(p.state) ? p.state : 'broken');\n const level: Level = rule.level === 'payload' ? (isLevel(p.level) ? p.level : 'error') : rule.level;\n const title = rule.title(p);\n const detail = str(p.reason ?? p.error ?? p.message ?? p.last_error_msg, '').slice(0, DETAIL_MAX);\n const now = iso();\n const eventId = String(row.id);\n const base: StateData = { key, state: next, level, event: row.event, detail, last_event_id: eventId, updated_at: now };\n\n const current = await store.byKey(key);\n if (!current) {\n // First sighting. A green first sighting is remembered silently \u2014 nothing was red.\n if (next === 'ok') { await store.create(base); return null; }\n const created = await store.create({ ...base, last_alert_at: now });\n return created ? format(level, next, title, detail, row.created_at) : null; // 409 = a racing run owns it\n }\n const prev = current.data.state ?? 'ok';\n if (prev !== next) {\n // A CROSSING. If-Match: exactly one concurrent run wins the transition.\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n if (!ok) return null;\n return next === 'ok'\n ? `\u{1F7E2} RESOLVED: ${title} is back to ok (was ${prev})`\n : format(level, next, title, detail, row.created_at);\n }\n // Same state. Red stays quiet \u2014 unless a reminder is due.\n if (next !== 'ok' && REPEAT_AFTER_HOURS > 0) {\n const last = Date.parse(current.data.last_alert_at ?? '') || 0;\n if (Date.now() - last >= REPEAT_AFTER_HOURS * 3600e3) {\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n return ok ? `\u23F0 STILL ${next.toUpperCase()}: ${title} (since ${current.data.last_alert_at ?? '?'})` : null;\n }\n }\n await store.patch(current.item_id, current.version, base); // keep detail/last_event_id fresh, no post\n return null;\n}\n\nfunction format(level: Level, state: State, title: string, detail: string, at?: string): string {\n const dot = level === 'error' ? '\u{1F534}' : level === 'warn' ? '\u{1F7E0}' : '\u{1F535}';\n return `${dot} [${level.toUpperCase()}] ${title} \u2192 ${state.toUpperCase()}${detail ? ` \u2014 ${detail}` : ''}${at ? ` (at ${at})` : ''}`;\n}\n\n// \u2500\u2500 the audit stream (ascending NDJSON export; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function exportAudit(base: string, key: string, afterId: string): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(`${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`, {\n headers: { authorization: `Bearer ${key}` },\n });\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items:[{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/alert_state?filter=${filter}&limit=1`, { headers: this.h() });\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 (unique key already claimed by a concurrent run) */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data, status: 'published' }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict (another run transitioned this key first) */\n async patch(itemId: string, version: number | undefined, data: Partial<StateData>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both,\n// EXCEPT to a Google Chat space webhook (chat.googleapis.com), which takes\n// `{ text }` alone \u2014 an unknown field can be rejected there, so it is dropped.\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n let host = '';\n try { host = new URL(url).hostname; } catch { /* unparseable \u2192 the generic body below */ }\n const body = host === 'chat.googleapis.com' ? { text } : { text, content: text };\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n\nconst iso = () => new Date().toISOString();\nconst isState = (v: unknown): v is State => v === 'ok' || v === 'stale' || v === 'broken' || v === 'could_not_check';\nconst isLevel = (v: unknown): v is Level => v === 'info' || v === 'warn' || v === 'error';\n"
18413
18065
  }
@@ -18430,7 +18082,7 @@ export default defineConfig({
18430
18082
  "slack_webhook_url"
18431
18083
  ],
18432
18084
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Payments heartbeat\" \u2014 THE SILENT-PROVIDER DETECTOR.\n//\n// Every other payments check answers \"did THIS event fold correctly?\". This one\n// answers the question nobody asks until it is too late: \"has my provider sent\n// me ANYTHING lately?\" A provider that stops posting produces no errors, no\n// failed webhooks, no red dashboard \u2014 just silence, and silence looks exactly\n// like a quiet week. This blueprint turns silence into a signal:\n// \u2022 payments \u2192 the webhook-event log (`GET /v1/payments/webhook-events`) is\n// already the record of every delivery your integration folded\n// \u2022 cms \u2192 `heartbeat_state`: one row per provider, so an alert fires on\n// the CROSSING and not once a day forever\n// \u2022 functions \u2192 `heartbeat`: a daily cron that measures days-since-last-\n// processed-event against YOUR OWN cadence and posts to Slack\n//\n// The threshold is not a number somebody picked: it is 3 \xD7 your median\n// inter-event gap, with a 7-day floor. A 100-subscriber app and a 100 000-\n// subscriber app have wildly different \"normal\", and a fixed number of days is\n// wrong for both.\n//\n// \u26A0 THIS IS AN OVERLAY, NOT A FRESH BACKEND. `vxil push` replaces a feature's\n// config wholesale. If you already run the payments feature, copy the\n// `heartbeat_state` collection and the `heartbeat` function into your EXISTING\n// vxil.config.ts rather than pushing this file \u2014 see README.md.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {},\n\n // Your payments INTEGRATION \u2014 your own provider account, your own keys;\n // vxil folds the lifecycle webhooks into a ledger and an entitlement\n // snapshot and is never in the flow of funds. Shown here so the file is a\n // coherent whole; if you already have a payments block, keep YOURS.\n payments: {\n provider: 'stripe',\n stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },\n defaults: { currency: 'usd' },\n },\n\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n // The heartbeat's memory: ONE row per provider. `provider` is unique, so\n // two overlapping cron ticks cannot both create it (the loser gets 409).\n heartbeat_state: {\n singular: 'heartbeat_state',\n fields: {\n provider: { type: 'string', required: true, indexSlot: 's1', unique: true },\n state: { type: 'string', indexSlot: 's2' }, // ok | stale | broken | could_not_check\n detail: { type: 'text' }, // the human sentence that was (or would be) posted\n days_since: { type: 'float', indexSlot: 'n1' }, // days since the last processed event\n threshold_days: { type: 'float', indexSlot: 'n2' }, // max(7, 3 \xD7 median gap)\n last_event_at: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_event_id: { type: 'string', indexSlot: 's3' },\n },\n },\n },\n },\n\n functions: {\n // Once a day is the right cadence for a check measured in DAYS. Also\n // invocable by hand \u2014 `vxil functions invoke heartbeat` \u2014 which runs the\n // real check and returns the per-provider verdict as JSON without posting\n // anything unless a state actually crossed.\n heartbeat: {\n entry: './functions/heartbeat.ts',\n trigger: { kind: 'cron', schedule: '0 7 * * *' },\n // payments:read = the webhook-event log; cms:* = the state rows. No write\n // scope on payments: a monitor must never be able to move money-adjacent\n // records, and this one structurally cannot.\n scopes: ['payments:read', 'cms:read', 'cms:write'],\n secrets: ['secret:slack_webhook_url'],\n egressAllow: ['hooks.slack.com'], // add 'discord.com' for Discord, 'chat.googleapis.com' for Google Chat\n },\n },\n\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n stripe_secret: { feature: 'payments', description: 'Stripe secret key (BYO \u2014 your own account)' },\n stripe_webhook: { feature: 'payments', description: 'Stripe webhook signing secret' },\n },\n});\n",
18433
- "readme": "# Payments heartbeat (ops)\n\nEvery other payments check answers *\"did this event fold correctly?\"*. This one answers the question\nnobody asks until it is too late: **\"has my provider sent me anything lately?\"**\n\nA provider that stops posting produces no errors, no failed webhooks, no red dashboard \u2014 just silence,\nand silence looks exactly like a quiet week. One daily cron function measures **days since the last\nprocessed production event, per provider**, against **your own cadence**, keeps a three-state\n`ok | stale | broken` record in `cms`, and posts to Slack (or Discord, or Google Chat) **once per crossing**.\n\n```bash\nvxil init --template payments-heartbeat\nvxil quickstart # or `vxil link <slug>` for an existing backend\nprintf '%s' \"$SLACK_URL\" | vxil secrets set functions/slack_webhook_url\nvxil push # the heartbeat_state collection + the cron function\nvxil functions invoke heartbeat # run the real check now; prints the per-provider verdict\n```\n\n> \u26A0 **This blueprint is an overlay.** `vxil push` writes each feature's config as a whole version, so\n> pushing this file at a backend that already runs payments would replace your payments block with the\n> example one below. If you already have a `payments` integration configured, copy just the\n> **`heartbeat_state` collection** and the **`heartbeat` function** into your existing `vxil.config.ts`\n> and push that. On a fresh backend, push this file as-is and edit the payments block to your provider.\n\n## The threshold is derived, not guessed\n\nA fixed \"alert after 14 days\" is wrong for a 100-subscriber app *and* for a 100 000-subscriber one. So the\nfunction computes it from your own traffic:\n\n```\nthreshold_days = max( 7 , 3 \xD7 median gap between your last 100 processed events )\n```\n\n- **`3 \xD7` the median gap** \u2014 the median, not the mean, because one migration backfill or one Black Friday\n would inflate a mean for months. Three is the \"this is no longer a quiet week\" multiple.\n- **A 7-day floor** \u2014 a low-volume integration can legitimately go a week without a single lifecycle\n event, and alerting on that is how a channel gets muted.\n- **Fewer than 5 gaps sampled** \u2192 there is no meaningful cadence yet, so the floor is used on its own.\n- Past **2 \xD7** the threshold, `stale` becomes `broken`. That severity split is this blueprint's choice \u2014\n tune `BROKEN_MULTIPLE` in `functions/heartbeat.ts`; every constant at the top of that file is policy.\n\n`ALERT_MULTIPLE = 3` and `FLOOR_DAYS = 7` are the numbers the platform's own payments-ingest SLO uses for\nits silent-provider detection SLI, so the blueprint and the platform agree by construction.\n\n## What it reads\n\n```\nGET /v1/payments/webhook-events?provider=<p>&environment=production&outcome=processed&limit=100\n```\n\nwith the function's `payments:read` scope \u2014 the webhook event log your integration already writes for\nevery delivery it folds. Three details in that URL are the whole design:\n\n| Part | Why |\n|---|---|\n| `provider=<p>` | asked **per provider**, in a loop. A global query would be dominated by your chattiest provider, and a quiet one that went silent months ago would never appear in the newest page at all. |\n| `environment=production` | sandbox traffic is developer noise. A provider can be chatty in test mode while production has been silent for a month \u2014 exactly the outage this exists to catch. |\n| `outcome=processed` | not merely *received*. An event that arrived and never folded is a different failure (`payments.webhook_event.failed`, which `templates/alerts-to-slack/` picks up); this function is about arrival. |\n\nA provider that has **never** delivered a processed production event is skipped entirely \u2014 there is no\ncadence to be silent against. Edit `PROVIDERS` in the function to match the ones you actually use.\n\n## States and messages\n\n| State | When | Message |\n|---|---|---|\n| `ok` | `days_since \u2264 threshold` | (silent \u2014 or \u{1F7E2} RESOLVED if it was not ok before) |\n| `stale` | `threshold < days_since \u2264 2 \xD7 threshold` | \u{1F7E0} `[STALE] payments heartbeat \u2014 \u2026` |\n| `broken` | `days_since > 2 \xD7 threshold` | \u{1F534} `[BROKEN] payments heartbeat \u2014 \u2026` |\n| `could_not_check` | the event-log read itself failed (feature disabled, scope missing, network) | \u{1F535} `[CHECK FAILED] \u2026` \u2014 a monitor that cannot see is not a monitor that says \"fine\" |\n\n```\n\u{1F534} [BROKEN] payments heartbeat \u2014 stripe has sent no processed production event for 23.4d \u2014\n expected one within 7d (median gap 1.2d \xD7 3, floor 7d)\n\u{1F7E2} RESOLVED: stripe is delivering again \u2014 last processed event 0.1d ago (was broken)\n```\n\n## Once per crossing\n\nOne `heartbeat_state` row per provider, `provider` declared `unique`. Cron delivery is at-least-once, so\ntwo ticks can overlap: the unique field means only one can *create* the row (the other gets `409`), and\nevery update carries `If-Match: <version>`, so only one can *transition* it (the other gets `409\nversion_conflict` and stands down). A provider that stays broken for a month costs exactly **one**\nmessage, not thirty \u2014 and the row keeps `days_since` / `threshold_days` / `last_event_at` fresh the\nwhole time, so the dashboard always shows the current measurement even while the channel is quiet.\n\n## The scopes\n\n`payments:read`, `cms:read`, `cms:write` \u2014 and deliberately **no** payments write scope. A monitor should\nnot be able to touch money-adjacent records, and this one structurally cannot: the function's callback\ntoken is minted from exactly these scopes, so there is no write path to reach for.\n\nEgress is deny-by-default: `egressAllow: ['hooks.slack.com']`. Add `'discord.com'` for a Discord webhook \u2014\nthe message body carries both Slack's `text` and Discord's `content`, so one payload works on either.\nAdd `'chat.googleapis.com'` for a Google Chat space webhook \u2014 that host takes `text` alone, and the function\nsends only that field to it.\n\n## What to learn from this\n\n- **A monitor's threshold should come from the system it monitors.** Reading your own median gap turns\n one alert rule into a rule that fits every tenant.\n- **Absence is the hardest signal.** Nothing emits an event when a provider goes quiet, so the check has\n to be a *sweep*, not a subscription \u2014 which is what cron functions are for.\n- **`could_not_check` is a real state.** Folding \"I could not look\" into \"everything is fine\" is how\n monitoring dies silently; giving it its own state is one line and saves an outage.\n\n**Pairs with:** `templates/alerts-to-slack/` \u2014 the same suppression pattern applied to failure *events*\nrather than to silence, so together they cover both halves of the money path. The two ship the same\nSlack/Discord formatter on purpose: each blueprint is a self-contained clone, and every file under a\ntemplate's `functions/` directory must be a declared entry point, so there is nowhere for a shared module\nto live. Copy the file, own the copy.\n",
18085
+ "readme": "# Payments heartbeat (ops)\n\nEvery other payments check answers *\"did this event fold correctly?\"*. This one answers the question\nnobody asks until it is too late: **\"has my provider sent me anything lately?\"**\n\nA provider that stops posting produces no errors, no failed webhooks, no red dashboard \u2014 just silence,\nand silence looks exactly like a quiet week. One daily cron function measures **days since the last\nprocessed production event, per provider**, against **your own cadence**, keeps a three-state\n`ok | stale | broken` record in `cms`, and posts to Slack (or Discord, or Google Chat) **once per crossing**.\n\n```bash\nvxil init --template payments-heartbeat\nvxil quickstart # or `vxil link <slug>` for an existing backend\nprintf '%s' \"$SLACK_URL\" | vxil secrets set functions/slack_webhook_url\nvxil push # the heartbeat_state collection + the cron function\nvxil functions invoke heartbeat # run the real check now; prints the per-provider verdict\n```\n\n> **Plan note.** The heartbeat function deploys on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n> \u26A0 **This blueprint is an overlay.** `vxil push` writes each feature's config as a whole version, so\n> pushing this file at a backend that already runs payments would replace your payments block with the\n> example one below. If you already have a `payments` integration configured, copy just the\n> **`heartbeat_state` collection** and the **`heartbeat` function** into your existing `vxil.config.ts`\n> and push that. On a fresh backend, push this file as-is and edit the payments block to your provider.\n\n## The threshold is derived, not guessed\n\nA fixed \"alert after 14 days\" is wrong for a 100-subscriber app *and* for a 100 000-subscriber one. So the\nfunction computes it from your own traffic:\n\n```\nthreshold_days = max( 7 , 3 \xD7 median gap between your last 100 processed events )\n```\n\n- **`3 \xD7` the median gap** \u2014 the median, not the mean, because one migration backfill or one Black Friday\n would inflate a mean for months. Three is the \"this is no longer a quiet week\" multiple.\n- **A 7-day floor** \u2014 a low-volume integration can legitimately go a week without a single lifecycle\n event, and alerting on that is how a channel gets muted.\n- **Fewer than 5 gaps sampled** \u2192 there is no meaningful cadence yet, so the floor is used on its own.\n- Past **2 \xD7** the threshold, `stale` becomes `broken`. That severity split is this blueprint's choice \u2014\n tune `BROKEN_MULTIPLE` in `functions/heartbeat.ts`; every constant at the top of that file is policy.\n\n`ALERT_MULTIPLE = 3` and `FLOOR_DAYS = 7` are the numbers the platform's own payments-ingest SLO uses for\nits silent-provider detection SLI, so the blueprint and the platform agree by construction.\n\n## What it reads\n\n```\nGET /v1/payments/webhook-events?provider=<p>&environment=production&outcome=processed&limit=100\n```\n\nwith the function's `payments:read` scope \u2014 the webhook event log your integration already writes for\nevery delivery it folds. Three details in that URL are the whole design:\n\n| Part | Why |\n|---|---|\n| `provider=<p>` | asked **per provider**, in a loop. A global query would be dominated by your chattiest provider, and a quiet one that went silent months ago would never appear in the newest page at all. |\n| `environment=production` | sandbox traffic is developer noise. A provider can be chatty in test mode while production has been silent for a month \u2014 exactly the outage this exists to catch. |\n| `outcome=processed` | not merely *received*. An event that arrived and never folded is a different failure (`payments.webhook_event.failed`, which `templates/alerts-to-slack/` picks up); this function is about arrival. |\n\nA provider that has **never** delivered a processed production event is skipped entirely \u2014 there is no\ncadence to be silent against. Edit `PROVIDERS` in the function to match the ones you actually use.\n\n## States and messages\n\n| State | When | Message |\n|---|---|---|\n| `ok` | `days_since \u2264 threshold` | (silent \u2014 or \u{1F7E2} RESOLVED if it was not ok before) |\n| `stale` | `threshold < days_since \u2264 2 \xD7 threshold` | \u{1F7E0} `[STALE] payments heartbeat \u2014 \u2026` |\n| `broken` | `days_since > 2 \xD7 threshold` | \u{1F534} `[BROKEN] payments heartbeat \u2014 \u2026` |\n| `could_not_check` | the event-log read itself failed (feature disabled, scope missing, network) | \u{1F535} `[CHECK FAILED] \u2026` \u2014 a monitor that cannot see is not a monitor that says \"fine\" |\n\n```\n\u{1F534} [BROKEN] payments heartbeat \u2014 stripe has sent no processed production event for 23.4d \u2014\n expected one within 7d (median gap 1.2d \xD7 3, floor 7d)\n\u{1F7E2} RESOLVED: stripe is delivering again \u2014 last processed event 0.1d ago (was broken)\n```\n\n## Once per crossing\n\nOne `heartbeat_state` row per provider, `provider` declared `unique`. Cron delivery is at-least-once, so\ntwo ticks can overlap: the unique field means only one can *create* the row (the other gets `409`), and\nevery update carries `If-Match: <version>`, so only one can *transition* it (the other gets `409\nversion_conflict` and stands down). A provider that stays broken for a month costs exactly **one**\nmessage, not thirty \u2014 and the row keeps `days_since` / `threshold_days` / `last_event_at` fresh the\nwhole time, so the dashboard always shows the current measurement even while the channel is quiet.\n\n## The scopes\n\n`payments:read`, `cms:read`, `cms:write` \u2014 and deliberately **no** payments write scope. A monitor should\nnot be able to touch money-adjacent records, and this one structurally cannot: the function's callback\ntoken is minted from exactly these scopes, so there is no write path to reach for.\n\nEgress is deny-by-default: `egressAllow: ['hooks.slack.com']`. Add `'discord.com'` for a Discord webhook \u2014\nthe message body carries both Slack's `text` and Discord's `content`, so one payload works on either.\nAdd `'chat.googleapis.com'` for a Google Chat space webhook \u2014 that host takes `text` alone, and the function\nsends only that field to it.\n\n## What to learn from this\n\n- **A monitor's threshold should come from the system it monitors.** Reading your own median gap turns\n one alert rule into a rule that fits every tenant.\n- **Absence is the hardest signal.** Nothing emits an event when a provider goes quiet, so the check has\n to be a *sweep*, not a subscription \u2014 which is what cron functions are for.\n- **`could_not_check` is a real state.** Folding \"I could not look\" into \"everything is fine\" is how\n monitoring dies silently; giving it its own state is one line and saves an outage.\n\n**Pairs with:** `templates/alerts-to-slack/` \u2014 the same suppression pattern applied to failure *events*\nrather than to silence, so together they cover both halves of the money path. The two ship the same\nSlack/Discord formatter on purpose: each blueprint is a self-contained clone, and every file under a\ntemplate's `functions/` directory must be a declared entry point, so there is nowhere for a shared module\nto live. Copy the file, own the copy.\n",
18434
18086
  "functions": {
18435
18087
  "heartbeat.ts": "// heartbeat.ts \u2014 THE SILENT-PROVIDER DETECTOR (a vxil function, cron trigger).\n//\n// Trigger: cron `0 7 * * *`. For each provider you list in PROVIDERS:\n// 1. read the newest PROCESSED production webhook events \u2014\n// `GET /v1/payments/webhook-events?provider=<p>&environment=production\n// &outcome=processed&limit=100` (newest first),\n// 2. days_since = now \u2212 the newest event's received_at,\n// 3. threshold = max(FLOOR_DAYS, ALERT_MULTIPLE \xD7 the MEDIAN gap between\n// those events) \u2014 your own cadence, not a number somebody picked,\n// 4. ok | stale | broken (or could_not_check when the read itself failed),\n// 5. compare with the stored row for that provider and post to Slack ONLY on\n// a crossing; a return to ok posts RESOLVED.\n//\n// Why the median and not the mean: one migration backfill or one Black Friday\n// inflates a mean for months. The median is what \"normal\" actually looks like.\n//\n// Why `?environment=production`: sandbox traffic is developer noise. A provider\n// can be chatty in test mode while production has been silent for a month \u2014\n// that is precisely the outage this function exists to catch.\n//\n// Run it by hand any time: `vxil functions invoke heartbeat`. It performs the\n// real check and returns the per-provider verdict as JSON; it still only posts\n// if a state genuinely crossed.\n\n// \u2500\u2500 Tune these. They are the whole policy. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n// cron-walk: single-read \u2014 a sample of the newest 100 events per provider is the measurement itself.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\n/** Providers to probe. Must be names the payments API accepts. Drop the ones\n * you do not use \u2014 a provider that has never sent an event is skipped anyway. */\nconst PROVIDERS = ['stripe', 'paddle', 'paypal', 'revenuecat'] as const;\n/** Alert once silence exceeds this multiple of your median inter-event gap. */\nconst ALERT_MULTIPLE = 3;\n/** \u2026but never sooner than this many days, however chatty your integration is.\n * A low-volume app can legitimately go a week without a single lifecycle event. */\nconst FLOOR_DAYS = 7;\n/** Past this multiple of the threshold, `stale` becomes `broken`. This split is\n * the blueprint's own choice \u2014 the SLO defines the alert threshold, not the\n * severity ladder \u2014 so move it wherever your escalation wants it. */\nconst BROKEN_MULTIPLE = 2;\n/** Gaps needed before a median means anything. Below this the floor is used. */\nconst MIN_GAPS_FOR_MEDIAN = 5;\n/** Events sampled per provider (the API caps a page at 100). */\nconst SAMPLE = 100;\n\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\n\ntype Envelope = CronFunctionEnvelope;\ninterface WebhookEvent { event_id: string; provider: string; received_at: string | null }\ninterface StateData {\n provider: string; state?: State; detail?: string;\n days_since?: number; threshold_days?: number;\n last_event_at?: string; checked_at?: string; last_event_id?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\ninterface Verdict {\n provider: string;\n state: State;\n detail: string;\n days_since: number | null;\n threshold_days: number | null;\n last_event_at: string | null;\n last_event_id: string | null;\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const payments = env.scoped_jwts?.payments;\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n if (!payments || !cms) return Response.json({ error: 'missing payments:read / cms scope' }, { status: 403 });\n if (!hook) return Response.json({ error: 'missing secret slack_webhook_url' }, { status: 409 });\n\n const store = new Store(base, cms);\n const checked: Verdict[] = [];\n const crossings: string[] = [];\n\n for (const provider of PROVIDERS) {\n const verdict = await check(base, payments, provider);\n if (!verdict) continue; // never sent an event \u2192 not part of this integration\n checked.push(verdict);\n const line = await reconcile(store, verdict);\n if (line) crossings.push(line);\n }\n\n let posted = 0;\n for (const text of crossings) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n return Response.json({ checked, crossings: crossings.length, posted });\n },\n};\n\n/** Measure one provider. `null` = the provider has never delivered a processed\n * production event, so there is no cadence to be silent against \u2014 the SLO's\n * \"once the tenant has ever received one\" precondition. */\nasync function check(base: string, jwt: string, provider: string): Promise<Verdict | null> {\n const url = `${base}/v1/payments/webhook-events`\n + `?provider=${encodeURIComponent(provider)}&environment=production&outcome=processed&limit=${SAMPLE}`;\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } }).catch(() => null);\n if (!res) {\n return verdict(provider, 'could_not_check', `could not reach the payments event log for ${provider}`);\n }\n if (!res.ok) {\n // 501 capability_not_enabled / 403 missing scope are configuration, not silence.\n return verdict(provider, 'could_not_check', `payments event log returned ${res.status} for ${provider}`);\n }\n const body = (await res.json().catch(() => ({}))) as { data?: { events?: WebhookEvent[] } };\n const events = (body.data?.events ?? []).filter((e) => e.received_at);\n if (events.length === 0) return null;\n\n // The API orders by received_at DESC, so [0] is the newest.\n const times = events.map((e) => Date.parse(e.received_at!)).filter(Number.isFinite).sort((a, b) => b - a);\n if (times.length === 0) return null;\n const last = times[0]!;\n const daysSince = round2((Date.now() - last) / 86_400_000);\n\n const gaps: number[] = [];\n for (let i = 0; i + 1 < times.length; i++) gaps.push((times[i]! - times[i + 1]!) / 86_400_000);\n const medianGap = gaps.length >= MIN_GAPS_FOR_MEDIAN ? median(gaps) : null;\n const threshold = round2(Math.max(FLOOR_DAYS, medianGap === null ? 0 : ALERT_MULTIPLE * medianGap));\n\n const state: State = daysSince <= threshold ? 'ok'\n : daysSince <= threshold * BROKEN_MULTIPLE ? 'stale'\n : 'broken';\n\n const cadence = medianGap === null\n ? `only ${gaps.length} gap(s) sampled \u2014 using the ${FLOOR_DAYS}-day floor`\n : `median gap ${round2(medianGap)}d \xD7 ${ALERT_MULTIPLE}, floor ${FLOOR_DAYS}d`;\n const detail = state === 'ok'\n ? `${provider}: last processed event ${daysSince}d ago (threshold ${threshold}d \u2014 ${cadence})`\n : `${provider} has sent no processed production event for ${daysSince}d \u2014 expected one within ${threshold}d (${cadence})`;\n\n return {\n provider, state, detail,\n days_since: daysSince,\n threshold_days: threshold,\n last_event_at: new Date(last).toISOString(),\n last_event_id: events[0]!.event_id ?? null,\n };\n}\n\n/** Persist the verdict; return a message ONLY when the state crossed. */\nasync function reconcile(store: Store, v: Verdict): Promise<string | null> {\n const now = new Date().toISOString();\n const data: StateData = {\n provider: v.provider, state: v.state, detail: v.detail, checked_at: now,\n ...(v.days_since !== null ? { days_since: v.days_since } : {}),\n ...(v.threshold_days !== null ? { threshold_days: v.threshold_days } : {}),\n ...(v.last_event_at ? { last_event_at: v.last_event_at } : {}),\n ...(v.last_event_id ? { last_event_id: v.last_event_id } : {}),\n };\n\n const current = await store.byProvider(v.provider);\n if (!current) {\n // First run. A healthy first sighting is remembered silently; an unhealthy\n // one is worth saying out loud immediately \u2014 you were already in the outage.\n const created = await store.create(data);\n return created && v.state !== 'ok' ? format(v) : null; // 409 \u21D2 a racing tick owns it\n }\n\n const prev = current.data.state ?? 'ok';\n if (prev === v.state) {\n // No crossing: refresh the measurement, stay quiet. A permanently-broken\n // provider therefore costs exactly one message, not one per day.\n await store.patch(current.item_id, current.version, data);\n return null;\n }\n // A CROSSING. If-Match makes exactly one of two overlapping ticks the winner.\n const ok = await store.patch(current.item_id, current.version, data);\n if (!ok) return null;\n return v.state === 'ok'\n ? `\u{1F7E2} RESOLVED: ${v.provider} is delivering again \u2014 last processed event ${v.days_since}d ago (was ${prev})`\n : format(v);\n}\n\nfunction format(v: Verdict): string {\n const dot = v.state === 'broken' ? '\u{1F534}' : v.state === 'stale' ? '\u{1F7E0}' : '\u{1F535}';\n const label = v.state === 'could_not_check' ? 'CHECK FAILED' : v.state.toUpperCase();\n return `${dot} [${label}] payments heartbeat \u2014 ${v.detail}`;\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items: [{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byProvider(provider: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ provider }));\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state?filter=${filter}&limit=1`, { headers: this.h() });\n if (!res.ok) return null;\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 \u2014 `provider` is unique, so a concurrent tick already claimed it. */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ status: 'published', data }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict \u2014 another tick transitioned this provider first. */\n async patch(itemId: string, version: number | undefined, data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both,\n// EXCEPT to a Google Chat space webhook (chat.googleapis.com), which takes\n// `{ text }` alone \u2014 an unknown field can be rejected there, so it is dropped.\n// (Duplicated from templates/alerts-to-slack on purpose: a blueprint is a\n// self-contained clone, and every file under functions/ must be a declared\n// entry point, so there is no place for a shared module to live.)\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n let host = '';\n try { host = new URL(url).hostname; } catch { /* unparseable \u2192 the generic body below */ }\n const body = host === 'chat.googleapis.com' ? { text } : { text, content: text };\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n\nfunction verdict(provider: string, state: State, detail: string): Verdict {\n return { provider, state, detail, days_since: null, threshold_days: null, last_event_at: null, last_event_id: null };\n}\nfunction median(xs: number[]): number {\n const s = [...xs].sort((a, b) => a - b);\n const mid = s.length >> 1;\n return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2;\n}\nconst round2 = (n: number) => Math.round(n * 100) / 100;\n"
18436
18088
  }
@@ -18454,8 +18106,8 @@ export default defineConfig({
18454
18106
  "byoKeys": [
18455
18107
  "vxil_read_key"
18456
18108
  ],
18457
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Job Runner\" \u2014 the BACKGROUND-WORK blueprint. Four ways work leaves the\n// request path, and what happens when it fails:\n//\n// \u2022 enqueue \u2192 one-off work, deduplicated by an idempotency key, and\n// optionally deferred (seconds from now, or an exact time)\n// \u2022 schedules \u2192 cron, with a LATE schedule reporting itself\n// \u2022 queue trigger \u2192 a deployed function that IS the worker\n// \u2022 generation \u2192 a long provider call vxil babysits for you, holding\n// credits while it runs and releasing them when it ends\n// \u2022 the spine \u2192 every failure is an audit event; one cron function turns\n// the ones that matter into `incidents` rows\n//\n// The credits half is a payments INTEGRATION with the deterministic `mock`\n// provider, so the whole reserve \u2192 settle \u2192 refund story runs with no provider\n// account at all. \"Credits\" here are usage units, not money.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n // a failing delivery is retried this many times before it dead-letters\n retry: { defaultMaxAttempts: 3 },\n retention: { successfulRunDays: 7, failedRunDays: 30 },\n // once this many runs have dead-lettered today, a further failing run\n // skips its remaining retries and dead-letters immediately \u2014 a bound on\n // the churn ONE pathological target can generate. 0 = unlimited.\n dlqDailyQuota: 200,\n concurrency: { maxConcurrent: 5 },\n schedules: { maxPerTenant: 20 },\n generation: {\n maxConcurrent: 5,\n defaultTimeoutMs: 300_000, // 5 min unless the descriptor says otherwise\n pollMaxAttempts: 30, // give up after 30 polls \u2192 terminal fail\n maxReserveCredits: 200, // a single run can never hold more than this\n maxOutstandingReserveCredits: 5_000, // \u2026nor can all in-flight runs together\n },\n },\n\n // The ledger half. `mock` is the deterministic default provider: no keys,\n // no account, the entire credits path exercisable end to end. Point it at\n // your own Stripe/Paddle/PayPal/RevenueCat account when you go live \u2014 vxil\n // is never in the flow of funds.\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n // product id \u2192 what buying it grants. Consumed by the provider webhook\n // reducer; the map is DATA, so adding products never changes the config.\n productMap: {\n render_pack_1000: { creditType: 'render_credits', amount: 1000, period: 'once' },\n },\n // tier \u2192 what being on it entitles you to, and what it tops up monthly\n tierMap: {\n pro: {\n entitlements: ['render'],\n quotas: { renders_per_day: 500 },\n rank: 10,\n grants: [{ creditType: 'render_credits', amount: 5_000, period: 'monthly' }],\n },\n },\n // a generation that ends in failure gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {},\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // The work items. One row per requested render.\n renders: {\n singular: 'render',\n fields: {\n // THE DEDUPE ANCHOR. Queue deliveries are at-least-once, so the\n // worker may see the same item twice; a unique field turns the second\n // write into a clean 409 the function treats as \"already done\".\n request_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n title: { type: 'string', indexSlot: 's2' },\n state: { type: 'string', indexSlot: 's3' }, // queued | processing | done | failed\n run_id: { type: 'string', indexSlot: 's4' }, // the jobs run that owns it\n credits: { type: 'int', indexSlot: 'n1' }, // held for this render\n created_at: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n\n // The failure memory. One row per incident KEY, so ten dead letters of\n // the same job are one row \u2014 plus the reserved `__cursor__` row holding\n // the audit-stream watermark.\n incidents: {\n singular: 'incident',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n event: { type: 'string', indexSlot: 's2' }, // the audit event that raised it\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n subject: { type: 'string', indexSlot: 's4' }, // job name / schedule id\n seen_count: { type: 'int', indexSlot: 'n1' },\n first_seen: { type: 'datetime', indexSlot: 't1' },\n last_seen: { type: 'datetime', indexSlot: 't2' },\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n detail: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // THE WORKER. A queue-triggered function is invoked by an enqueue, not by a\n // request \u2014 it has no URL a browser can reach. Delivery is at-least-once,\n // so it dedupes on `request_key` rather than assuming exactly-once.\n 'process-batch': {\n entry: './functions/process-batch.ts',\n trigger: { kind: 'queue', source: 'renders' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // THE FAILURE DRAIN. Every feature writes its lifecycle to your tenant's\n // audit stream; this reads the stream past a watermark every 5 minutes and\n // turns the failure events that matter into `incidents` rows.\n 'incident-watch': {\n entry: './functions/incident-watch.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' },\n scopes: ['cms:read', 'cms:write'],\n // The audit stream is a control-plane read, not one of the feature APIs\n // the function's scoped callback covers \u2014 so the drain reads it with the\n // narrowest key that can: one holding ONLY `features:read`.\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n },\n },\n\n secrets: {\n vxil_read_key: {\n feature: 'functions',\n description: 'a vxil API key of this backend holding ONLY features:read \u2014 reads the audit stream',\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'renders',\n items: [\n {\n request_key: 'seed-0001',\n title: 'Quarterly report render',\n state: 'queued',\n credits: 10,\n created_at: '2026-04-01T08:00:00Z',\n notes: 'Seed row so the collection is not empty on first push.',\n },\n ],\n },\n ],\n },\n});\n",
18458
- "readme": '# Job Runner (ops)\n\nFour ways work leaves the request path, and one answer for what happens when it fails. If you have\never written a `jobs` table, a worker loop, a retry counter and a "why did this run twice?" post\nmortem, this is that, declared.\n\n```bash\nvxil init --template job-runner\nvxil quickstart # or `vxil link <slug>`\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key\nvxil push # collections + both functions\n```\n\n`vxil_read_key` is an API key **of this same backend** holding **only `features:read`** \u2014 the\nincident drain reads the audit stream with it (dashboard \u2192 API keys \u2192 create, tick `features:read`\nand nothing else). Everything else here needs no credentials at all: the payments integration runs\non the deterministic `mock` provider, so the whole credits story works before you have a provider\naccount. "Credits" are usage units you meter, not money and not stored value.\n\n## The four ways work leaves the request path\n\n| | You call | It runs | Use it when |\n|---|---|---|---|\n| **Enqueue** | `POST /v1/jobs/enqueue` | now, or later | one-off work, deduplicated by a key you choose |\n| **Schedule** | `POST /v1/jobs/schedules` | on a 5-field UTC cron | recurring work \u2014 and it tells you when it ran late |\n| **Queue trigger** | `vxil functions invoke <fn> --async` | a deployed function | the worker IS your code, with no URL to expose |\n| **Generation** | `POST /v1/jobs/generation` | a long provider call vxil babysits | a model/render/export that answers in minutes, not milliseconds |\n\nAnd one answer for failure: **every feature writes its lifecycle to your audit stream**, and the\n`incident-watch` cron turns the failures that matter into rows you can query.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `jobs:read jobs:write payments:read payments:write cms:read cms:write\nfunctions:read functions:invoke features:read`.\n\n**1. Enqueue the same thing twice.** The dedupe scope is `(job_name, idempotency_key)`:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deduplicated": true } } \u2190 the SAME run_id\n```\n\nNote where the key goes: **in the body**, as `idempotency_key`. (The `Idempotency-Key` *header* is\nwhat the notifications and payments surfaces read \u2014 jobs reads the field.) The window is 24 hours,\nand it is backstopped in the database, so two racing enqueues cannot both win.\n\n**2. Run it later.** Two mutually exclusive fields \u2014 pick one:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","delay_seconds":900}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deliver_after": "\u2026T12:26:12.686Z" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","deliver_after":"2026-12-24T09:00:00Z"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "delayed", "deliver_after": "2026-12-24T09:00:00.000Z" } }\n```\n\n`state` tells you which machinery is holding it: a short delay rides the queue itself and fires on\nthe second; anything beyond twelve hours is parked as `delayed` and released by a minute-tick\nsweep. The ceiling is 30 days. A `deliver_after` in the past is a 422, not a surprise.\n\n**3. A schedule that reports itself late.**\n\n```bash\nvxil api POST /v1/jobs/schedules --data \'{"job_name":"nightly.rollup","target_url":"https://hooks.example.com/rollup","cron":"0 2 * * *"}\'\n# 201 { "data": { "schedule_id": "sch_\u2026", "next_run_at": "2026-09-12T02:00:00.000Z", "state": "active" } }\n```\n\nFive UTC fields, with `*`, numbers, lists, ranges and steps \u2014 no month or weekday names, no `L`/`W`.\nWhen a tick finally fires more than **twice its own interval** late, the platform writes\n`jobs.schedule.missed` (once per crossing, not once per tick) and `jobs.schedule.recovered` when it\ncatches up. Step 7 turns both into rows.\n\n**4. The worker is your function.** `process-batch` is queue-triggered \u2014 it has no URL a browser can\nreach. Hand it a batch:\n\n```bash\nvxil functions invoke process-batch --async --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api GET "/v1/cms/items/renders?filter=%7B%22state%22%3A%22queued%22%7D"\n# 200 \u2026 two new rows, request_key "b-1:a" and "b-1:b"\n```\n\n(`--async` enqueues it; drop the flag to call the same function synchronously and\nread its answer. `--async` needs a linked project \u2014 `vxil quickstart` or `vxil link <slug>`.)\n\nNow send the **exact same batch again**, synchronously so you can read the verdict:\n\n```bash\nvxil functions invoke process-batch --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# { "batch_id": "b-1", "created": 0, "duplicates": 2, "rejected": 0 }\n```\n\nNothing was duplicated, and the function contains no dedupe logic. `request_key` is declared\n`unique`, so the second create is a 409 \u2014 and the function reads a 409 as *already done*. That is\nthe whole strategy: **delivery is at-least-once, so correctness lives in a declared field, not in a\nhope that it runs once.**\n\n**5. Credits: reserve \u2192 settle.** Grant some usage units first (the header is required here \u2014 this\nis the money-shaped surface):\n\n```bash\ncurl -s -X POST "https://api.vxil.com/v1/payments/credits/grant" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -H \'idempotency-key: seed-1\' \\\n -d \'{"user_id":"u_demo","credit_type":"render_credits","amount":100,"source":"seed"}\'\n# 200 { "data": { "balance_after": 100, "ledger_entry_id": null } }\n\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "credit_type": "render_credits", "balance": 100, "held": 0, "available": 100, \u2026 } }\n```\n\nNow start a generation that holds ten of them while it runs:\n\n```bash\nvxil api POST /v1/jobs/generation --data \'{\n "job_name": "render.deck",\n "provider": { "url": "https://api.your-render-provider.example/v1/renders",\n "method": "POST",\n "body": { "format": "pdf" } },\n "completion": { "mode": "poll",\n "status_path": "status",\n "poll": { "url": "https://api.your-render-provider.example/v1/renders/latest",\n "method": "GET", "interval_ms": 5000 } },\n "reserve_credits": { "amount": 10, "user_id": "u_demo",\n "credit_type": "render_credits", "reason": "deck render" },\n "timeout": { "after_ms": 120000 },\n "payload": { "render_id": "b-1:a" }\n}\'\n# 202 { "data": { "run_id": "run_\u2026", "generation_status": "pending", "state": "queued" } }\n```\n\nImmediately, the balance moves \u2014 but only the **held** half:\n\n```bash\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "balance": 100, "held": 10, "available": 90, \u2026 } }\n\nvxil api GET "/v1/payments/usage?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "user_id": "u_demo", "entries": [\n# { "kind": "consume", "state": "provisional", "delta": -10, "job_id": "run_\u2026", "source": "deck render", \u2026 },\n# { "kind": "grant", "state": "committed", "delta": 100, \u2026 } ] } }\n```\n\n`state: "provisional"` is the reservation. Nothing has been spent yet \u2014 `balance` is untouched and\n`available` dropped, so the same user cannot start ten more renders on credits they do not have.\n\nWhen the run reaches a terminal state, vxil settles it for you, and the ledger says which way it\nwent:\n\n- **completed** \u2192 the provisional row flips to `committed` and a `settle` row lands with\n `source: "job:succeeded"`. The credits were spent.\n- **failed / timed out** \u2192 a `reversal` row lands with a **positive** delta and\n `source: "job:failed"`, the original flips to `reversed`, and `held` returns to zero. The\n customer was not charged for work that did not happen. That refund is the\n `ledger.autoRefundOnJobFailure` flag in `vxil.config.ts`; turn it off and a failure still releases\n the hold but keeps the charge.\n\nTwo ceilings you do not have to remember to set: a single run\'s request is **clamped** to\n`generation.maxReserveCredits` (never rejected, so a bad caller cannot break the flow), and the sum\nof all outstanding holds is refused past `generation.maxOutstandingReserveCredits` with a 429. Both\nhave safe defaults.\n\nPoll mode is exactly what it says: vxil calls your provider once, then re-reads\n`completion.poll.url` every `interval_ms`, reading `status_path` out of the body. Anything it does\nnot recognise counts as *still processing* \u2014 a generation is never silently completed by a typo.\nAfter `pollMaxAttempts` it fails terminally, and the timeout does the same on the wall clock.\n\n**6. Dead letters and replay.** There is no separate dead-letter inbox \u2014 a dead letter is a run in\nthe `dead` state:\n\n```bash\nvxil api GET "/v1/jobs/runs?state=dead&limit=20"\n# 200 { "data": { "runs": [ { "run_id": "run_\u2026", "job_name": "report.email", "state": "dead",\n# "attempt_number": 1, "max_attempts": 1,\n# "last_error_class": "NonRetryableHttp",\n# "last_error_msg": "target returned 404", \u2026 } ] } }\n\nvxil api POST /v1/jobs/runs/run_\u2026/replay\n# 202 { "data": { "run_id": "run_NEW", "replayed_from": "run_\u2026", "state": "queued" } }\n```\n\nReplay takes **no body** and clones the original into a *new* run \u2014 the dead row is evidence and\nstays untouched. Only terminal runs replay; anything still in flight is a `409 not_replayable`.\n\n**7. Failures become rows.** `incident-watch` runs every five minutes. Force it once:\n\n```bash\nvxil functions invoke incident-watch\n# first run: { "initialized": true, "cursor": "\u2026", "raised": 0 } \u2190 history never floods you\n# after a dead letter, the next run:\n# { "scanned": 12, "raised": 1, "resolved": 0, "cursor": "\u2026" }\n\nvxil api GET /v1/cms/items/incidents\n# 200 \u2026 { "key": "jobs:dead-letter:report.email", "event": "job.dead_lettered", "level": "error",\n# "subject": "report.email", "seen_count": 1, "first_seen": "\u2026", "last_seen": "\u2026",\n# "detail": "run run_\u2026 died after attempt 1" }\n```\n\nTen dead letters of the same job produce **one** row with `seen_count: 10`, because the incident\nkey collapses them. `jobs.schedule.missed` and `jobs.schedule.recovered` share a key, so a schedule\nthat catches up closes its own incident. The `RULES` table in `functions/incident-watch.ts` is the\nallow-list \u2014 plain data. Widen it from the catalog of everything the platform can emit:\n\n```bash\nvxil api GET /v1/webhooks/events/catalog\n# 200 { "data": { "count": 170, "events": [ { "name": "job.dead_lettered", "feature": "jobs",\n# "level": "failure", "payload_keys": [ \u2026 ] } \u2026 ],\n# "prefixes": [ { "prefix": "payments.", "count": 21 }, \u2026 ] } } \u2190 34 prefixes\n```\n\nIf you would rather the same events went to **your own** endpoint than into a collection, subscribe\nto the spine directly \u2014 same events, different consumer:\n\n```bash\nvxil api POST /v1/webhooks/subscriptions --data \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["job.","jobs."]}\'\n```\n\n## What to learn from this\n\n- **Exactly-once is not a delivery guarantee you can buy; it is a field you declare.** `unique` on\n `request_key`, `idempotency_key` on the enqueue, `foreign_id` elsewhere \u2014 each converts\n at-least-once delivery into an at-most-once *effect*.\n- **A reservation is not a charge.** Holding credits while long work runs, and releasing them if it\n fails, is the difference between metering and billing people for your outages. The ledger shows\n both halves, so you can answer "why was I charged?" from a query.\n- **Failure needs a vocabulary, not a log.** `job.dead_lettered`, `jobs.schedule.missed`,\n `job.generation.failed` are named events with stable payloads \u2014 that is why a 40-line function can\n turn them into an incident board, and why the catalog route can tell an agent what exists.\n- **Late is a different failure from broken.** A schedule that fires twice its interval late says so\n once, and says so again when it recovers. Alerting on every tick teaches people to mute you.\n- **The clamp beats the rejection.** Capping a requested hold, rather than refusing it, keeps a\n careless caller from breaking the flow while still bounding the blast radius.\n\n**Pairs with:** `templates/alerts-to-slack/` (the same drain, routed to a chat channel instead of a\ncollection) and `templates/payments-heartbeat/` (noticing the failure that is *silence*).\n',
18109
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Job Runner\" \u2014 the BACKGROUND-WORK blueprint. Four ways work leaves the\n// request path, and what happens when it fails:\n//\n// \u2022 enqueue \u2192 one-off work, deduplicated by an idempotency key, and\n// optionally deferred (seconds from now, or an exact time)\n// \u2022 schedules \u2192 cron, with a LATE schedule reporting itself\n// \u2022 queue trigger \u2192 a deployed function that IS the worker\n// \u2022 generation \u2192 a long provider call vxil babysits for you, holding\n// credits while it runs and releasing them when it ends\n// \u2022 the spine \u2192 every failure is an audit event; one cron function turns\n// the ones that matter into `incidents` rows\n//\n// The credits half is a payments INTEGRATION with the deterministic `mock`\n// provider, so the whole reserve \u2192 settle \u2192 refund story runs with no provider\n// account at all. \"Credits\" here are usage units, not money.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n // a failing delivery is retried this many times before it dead-letters\n retry: { defaultMaxAttempts: 3 },\n retention: { successfulRunDays: 7, failedRunDays: 30 },\n // once this many runs have dead-lettered today, a further failing run\n // skips its remaining retries and dead-letters immediately \u2014 a bound on\n // the churn ONE pathological target can generate. 0 = unlimited.\n dlqDailyQuota: 200,\n concurrency: { maxConcurrent: 5 },\n schedules: { maxPerTenant: 20 },\n generation: {\n maxConcurrent: 5,\n defaultTimeoutMs: 300_000, // 5 min unless the descriptor says otherwise\n pollMaxAttempts: 30, // give up after 30 polls \u2192 terminal fail\n maxReserveCredits: 200, // a single run can never hold more than this\n maxOutstandingReserveCredits: 5_000, // \u2026nor can all in-flight runs together\n },\n },\n\n // The ledger half. `mock` is the deterministic default provider: no keys,\n // no account, the entire credits path exercisable end to end. Point it at\n // your own Stripe/Paddle/PayPal/RevenueCat account when you go live \u2014 vxil\n // is never in the flow of funds.\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n // product id \u2192 what buying it grants. Consumed by the provider webhook\n // reducer; the map is DATA, so adding products never changes the config.\n productMap: {\n render_pack_1000: { creditType: 'render_credits', amount: 1000, period: 'once' },\n },\n // tier \u2192 what being on it entitles you to, and what it tops up monthly\n tierMap: {\n pro: {\n entitlements: ['render'],\n quotas: { renders_per_day: 500 },\n rank: 10,\n grants: [{ creditType: 'render_credits', amount: 5_000, period: 'monthly' }],\n },\n },\n // a generation that ends in failure gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {},\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // The work items. One row per requested render.\n renders: {\n singular: 'render',\n fields: {\n // THE DEDUPE ANCHOR. Queue deliveries are at-least-once, so the\n // worker may see the same item twice; a unique field turns the second\n // write into a clean 409 the function treats as \"already done\".\n request_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n title: { type: 'string', indexSlot: 's2' },\n state: { type: 'string', indexSlot: 's3' }, // queued | processing | done | failed\n run_id: { type: 'string', indexSlot: 's4' }, // the jobs run that owns it\n credits: { type: 'int', indexSlot: 'n1' }, // held for this render\n created_at: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n\n // The failure memory. One row per incident KEY, so ten dead letters of\n // the same job are one row \u2014 plus the reserved `__cursor__` row holding\n // the audit-stream watermark.\n incidents: {\n singular: 'incident',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n event: { type: 'string', indexSlot: 's2' }, // the audit event that raised it\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n subject: { type: 'string', indexSlot: 's4' }, // job name / schedule id\n seen_count: { type: 'int', indexSlot: 'n1' },\n first_seen: { type: 'datetime', indexSlot: 't1' },\n last_seen: { type: 'datetime', indexSlot: 't2' },\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n detail: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // THE WORKER. A queue-triggered function is invoked by an enqueue, not by a\n // request \u2014 it has no URL a browser can reach. Delivery is at-least-once,\n // so it dedupes on `request_key` rather than assuming exactly-once.\n 'process-batch': {\n entry: './functions/process-batch.ts',\n trigger: { kind: 'queue', source: 'renders' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // THE FAILURE DRAIN. Every feature writes its lifecycle to your tenant's\n // audit stream; this reads the stream past a watermark every 5 minutes and\n // turns the failure events that matter into `incidents` rows.\n 'incident-watch': {\n entry: './functions/incident-watch.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free plan: '*/15 * * * *' (its fastest function cron)\n scopes: ['cms:read', 'cms:write'],\n // The audit stream is a control-plane read, not one of the feature APIs\n // the function's scoped callback covers \u2014 so the drain reads it with the\n // narrowest key that can: one holding ONLY `features:read`.\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n },\n },\n\n secrets: {\n vxil_read_key: {\n feature: 'functions',\n description: 'a vxil API key of this backend holding ONLY features:read \u2014 reads the audit stream',\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'renders',\n items: [\n {\n request_key: 'seed-0001',\n title: 'Quarterly report render',\n state: 'queued',\n credits: 10,\n created_at: '2026-04-01T08:00:00Z',\n notes: 'Seed row so the collection is not empty on first push.',\n },\n ],\n },\n ],\n },\n});\n",
18110
+ "readme": '# Job Runner (ops)\n\nFour ways work leaves the request path, and one answer for what happens when it fails. If you have\never written a `jobs` table, a worker loop, a retry counter and a "why did this run twice?" post\nmortem, this is that, declared.\n\n```bash\nvxil init --template job-runner\nvxil quickstart --env staging --no-push # or `vxil link <slug> --env staging`\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key\nvxil push # collections + both functions\n```\n\n> **Plan note.** The two functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n`vxil_read_key` is an API key **of this same backend** holding **only `features:read`** \u2014 the\nincident drain reads the audit stream with it (dashboard \u2192 API keys \u2192 create, tick `features:read`\nand nothing else). Everything else here needs no credentials at all: the payments integration runs\non the deterministic `mock` provider, so the whole credits story works before you have a provider\naccount. "Credits" are usage units you meter, not money and not stored value.\n\n> **On the Free plan** a function cron may run at most every 15 minutes \u2014 change `incident-watch` to\n> `*/15 * * * *` in `vxil.config.ts` before `vxil push`, or the deploy answers `402 plan_limit`\n> (`cron_interval`). Paid plans run the 5-minute schedule as shipped.\n\n## The four ways work leaves the request path\n\n| | You call | It runs | Use it when |\n|---|---|---|---|\n| **Enqueue** | `POST /v1/jobs/enqueue` | now, or later | one-off work, deduplicated by a key you choose |\n| **Schedule** | `POST /v1/jobs/schedules` | on a 5-field UTC cron | recurring work \u2014 and it tells you when it ran late |\n| **Queue trigger** | `vxil functions invoke <fn> --async` | a deployed function | the worker IS your code, with no URL to expose |\n| **Generation** | `POST /v1/jobs/generation` | a long provider call vxil babysits | a model/render/export that answers in minutes, not milliseconds |\n\nAnd one answer for failure: **every feature writes its lifecycle to your audit stream**, and the\n`incident-watch` cron turns the failures that matter into rows you can query.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `jobs:read jobs:write payments:read payments:write cms:read cms:write\nfunctions:read functions:invoke features:read`.\n\n**1. Enqueue the same thing twice.** The dedupe scope is `(job_name, idempotency_key)`:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deduplicated": true } } \u2190 the SAME run_id\n```\n\nNote where the key goes: **in the body**, as `idempotency_key`. (The `Idempotency-Key` *header* is\nwhat the notifications and payments surfaces read \u2014 jobs reads the field.) The window is 24 hours,\nand it is backstopped in the database, so two racing enqueues cannot both win.\n\n**2. Run it later.** Two mutually exclusive fields \u2014 pick one:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","delay_seconds":900}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deliver_after": "\u2026T12:26:12.686Z" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","deliver_after":"2026-12-24T09:00:00Z"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "delayed", "deliver_after": "2026-12-24T09:00:00.000Z" } }\n```\n\n`state` tells you which machinery is holding it: a short delay rides the queue itself and fires on\nthe second; anything beyond twelve hours is parked as `delayed` and released by a minute-tick\nsweep. The ceiling is 30 days. A `deliver_after` in the past is a 422, not a surprise.\n\n**3. A schedule that reports itself late.**\n\n```bash\nvxil api POST /v1/jobs/schedules --data \'{"job_name":"nightly.rollup","target_url":"https://hooks.example.com/rollup","cron":"0 2 * * *"}\'\n# 201 { "data": { "schedule_id": "sch_\u2026", "next_run_at": "2026-09-12T02:00:00.000Z", "state": "active" } }\n```\n\nFive UTC fields, with `*`, numbers, lists, ranges and steps \u2014 no month or weekday names, no `L`/`W`.\nWhen a tick finally fires more than **twice its own interval** late, the platform writes\n`jobs.schedule.missed` (once per crossing, not once per tick) and `jobs.schedule.recovered` when it\ncatches up. Step 7 turns both into rows.\n\n**4. The worker is your function.** `process-batch` is queue-triggered \u2014 it has no URL a browser can\nreach. Hand it a batch:\n\n```bash\nvxil functions invoke process-batch --async --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api GET "/v1/cms/items/renders?filter=%7B%22state%22%3A%22queued%22%7D"\n# 200 \u2026 two new rows, request_key "b-1:a" and "b-1:b"\n```\n\n(`--async` enqueues it; drop the flag to call the same function synchronously and\nread its answer. `--async` needs a linked project \u2014 `vxil quickstart` or `vxil link <slug>`.)\n\nNow send the **exact same batch again**, synchronously so you can read the verdict:\n\n```bash\nvxil functions invoke process-batch --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# { "batch_id": "b-1", "created": 0, "duplicates": 2, "rejected": 0 }\n```\n\nNothing was duplicated, and the function contains no dedupe logic. `request_key` is declared\n`unique`, so the second create is a 409 \u2014 and the function reads a 409 as *already done*. That is\nthe whole strategy: **delivery is at-least-once, so correctness lives in a declared field, not in a\nhope that it runs once.**\n\n**5. Credits: reserve \u2192 settle.** Grant some usage units first (the header is required here \u2014 this\nis the money-shaped surface):\n\n```bash\ncurl -s -X POST "https://api.vxil.com/v1/payments/credits/grant" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -H \'idempotency-key: seed-1\' \\\n -d \'{"user_id":"u_demo","credit_type":"render_credits","amount":100,"source":"seed"}\'\n# 200 { "data": { "balance_after": 100, "ledger_entry_id": null } }\n\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "credit_type": "render_credits", "balance": 100, "held": 0, "available": 100, \u2026 } }\n```\n\nNow start a generation that holds ten of them while it runs:\n\n```bash\nvxil api POST /v1/jobs/generation --data \'{\n "job_name": "render.deck",\n "provider": { "url": "https://api.your-render-provider.example/v1/renders",\n "method": "POST",\n "body": { "format": "pdf" } },\n "completion": { "mode": "poll",\n "status_path": "status",\n "poll": { "url": "https://api.your-render-provider.example/v1/renders/latest",\n "method": "GET", "interval_ms": 5000 } },\n "reserve_credits": { "amount": 10, "user_id": "u_demo",\n "credit_type": "render_credits", "reason": "deck render" },\n "timeout": { "after_ms": 120000 },\n "payload": { "render_id": "b-1:a" }\n}\'\n# 202 { "data": { "run_id": "run_\u2026", "generation_status": "pending", "state": "queued" } }\n```\n\nImmediately, the balance moves \u2014 but only the **held** half:\n\n```bash\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "balance": 100, "held": 10, "available": 90, \u2026 } }\n\nvxil api GET "/v1/payments/usage?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "user_id": "u_demo", "entries": [\n# { "kind": "consume", "state": "provisional", "delta": -10, "job_id": "run_\u2026", "source": "deck render", \u2026 },\n# { "kind": "grant", "state": "committed", "delta": 100, \u2026 } ] } }\n```\n\n`state: "provisional"` is the reservation. Nothing has been spent yet \u2014 `balance` is untouched and\n`available` dropped, so the same user cannot start ten more renders on credits they do not have.\n\nWhen the run reaches a terminal state, vxil settles it for you, and the ledger says which way it\nwent:\n\n- **completed** \u2192 the provisional row flips to `committed` and a `settle` row lands with\n `source: "job:succeeded"`. The credits were spent.\n- **failed / timed out** \u2192 a `reversal` row lands with a **positive** delta and\n `source: "job:failed"`, the original flips to `reversed`, and `held` returns to zero. The\n customer was not charged for work that did not happen. That refund is the\n `ledger.autoRefundOnJobFailure` flag in `vxil.config.ts`; turn it off and a failure still releases\n the hold but keeps the charge.\n\nTwo ceilings you do not have to remember to set: a single run\'s request is **clamped** to\n`generation.maxReserveCredits` (never rejected, so a bad caller cannot break the flow), and the sum\nof all outstanding holds is refused past `generation.maxOutstandingReserveCredits` with a 429. Both\nhave safe defaults.\n\nPoll mode is exactly what it says: vxil calls your provider once, then re-reads\n`completion.poll.url` every `interval_ms`, reading `status_path` out of the body. Anything it does\nnot recognise counts as *still processing* \u2014 a generation is never silently completed by a typo.\nAfter `pollMaxAttempts` it fails terminally, and the timeout does the same on the wall clock.\n\n**6. Dead letters and replay.** There is no separate dead-letter inbox \u2014 a dead letter is a run in\nthe `dead` state:\n\n```bash\nvxil api GET "/v1/jobs/runs?state=dead&limit=20"\n# 200 { "data": { "runs": [ { "run_id": "run_\u2026", "job_name": "report.email", "state": "dead",\n# "attempt_number": 1, "max_attempts": 1,\n# "last_error_class": "NonRetryableHttp",\n# "last_error_msg": "target returned 404", \u2026 } ] } }\n\nvxil api POST /v1/jobs/runs/run_\u2026/replay\n# 202 { "data": { "run_id": "run_NEW", "replayed_from": "run_\u2026", "state": "queued" } }\n```\n\nReplay takes **no body** and clones the original into a *new* run \u2014 the dead row is evidence and\nstays untouched. Only terminal runs replay; anything still in flight is a `409 not_replayable`.\n\n**7. Failures become rows.** `incident-watch` runs every five minutes (every fifteen on the Free plan). Force it once:\n\n```bash\nvxil functions invoke incident-watch\n# first run: { "initialized": true, "cursor": "\u2026", "raised": 0 } \u2190 history never floods you\n# after a dead letter, the next run:\n# { "scanned": 12, "raised": 1, "resolved": 0, "cursor": "\u2026" }\n\nvxil api GET /v1/cms/items/incidents\n# 200 \u2026 { "key": "jobs:dead-letter:report.email", "event": "job.dead_lettered", "level": "error",\n# "subject": "report.email", "seen_count": 1, "first_seen": "\u2026", "last_seen": "\u2026",\n# "detail": "run run_\u2026 died after attempt 1" }\n```\n\nTen dead letters of the same job produce **one** row with `seen_count: 10`, because the incident\nkey collapses them. `jobs.schedule.missed` and `jobs.schedule.recovered` share a key, so a schedule\nthat catches up closes its own incident. The `RULES` table in `functions/incident-watch.ts` is the\nallow-list \u2014 plain data. Widen it from the catalog of everything the platform can emit:\n\n```bash\nvxil api GET /v1/webhooks/events/catalog\n# 200 { "data": { "count": 170, "events": [ { "name": "job.dead_lettered", "feature": "jobs",\n# "level": "failure", "payload_keys": [ \u2026 ] } \u2026 ],\n# "prefixes": [ { "prefix": "payments.", "count": 21 }, \u2026 ] } } \u2190 34 prefixes\n```\n\nIf you would rather the same events went to **your own** endpoint than into a collection, subscribe\nto the spine directly \u2014 same events, different consumer:\n\n```bash\nvxil api POST /v1/webhooks/subscriptions --data \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["job.","jobs."]}\'\n```\n\n## What to learn from this\n\n- **Exactly-once is not a delivery guarantee you can buy; it is a field you declare.** `unique` on\n `request_key`, `idempotency_key` on the enqueue, `foreign_id` elsewhere \u2014 each converts\n at-least-once delivery into an at-most-once *effect*.\n- **A reservation is not a charge.** Holding credits while long work runs, and releasing them if it\n fails, is the difference between metering and billing people for your outages. The ledger shows\n both halves, so you can answer "why was I charged?" from a query.\n- **Failure needs a vocabulary, not a log.** `job.dead_lettered`, `jobs.schedule.missed`,\n `job.generation.failed` are named events with stable payloads \u2014 that is why a 40-line function can\n turn them into an incident board, and why the catalog route can tell an agent what exists.\n- **Late is a different failure from broken.** A schedule that fires twice its interval late says so\n once, and says so again when it recovers. Alerting on every tick teaches people to mute you.\n- **The clamp beats the rejection.** Capping a requested hold, rather than refusing it, keeps a\n careless caller from breaking the flow while still bounding the blast radius.\n\n**Pairs with:** `templates/alerts-to-slack/` (the same drain, routed to a chat channel instead of a\ncollection) and `templates/payments-heartbeat/` (noticing the failure that is *silence*).\n',
18459
18111
  "functions": {
18460
18112
  "incident-watch.ts": "// incident-watch.ts \u2014 FAILURE EVENTS \u2192 `incidents` ROWS (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Every feature writes its lifecycle to your\n// tenant's AUDIT STREAM \u2014 that stream is the event spine, and this function is\n// one consumer of it. Each run:\n// 1. reads the watermark (the reserved `__cursor__` row in `incidents`; the\n// first run stores the NEWEST id and stops, so history never floods you),\n// 2. drains `GET /v1/audit/export?after_id=\u2026` (ascending NDJSON) past it,\n// 3. matches each row against RULES \u2014 an allow-list, not a firehose,\n// 4. upserts ONE row per incident key (ten dead letters of the same job are\n// one row with `seen_count: 10`, not ten rows),\n// 5. advances the watermark with If-Match, so an overlapping tick stands down\n// instead of double-counting.\n//\n// WHY A KEY: the audit stream is a platform read, not one of the feature APIs\n// the function's scoped callback covers \u2014 so the drain uses the narrowest key\n// that can reach it, one holding ONLY `features:read`, stored as a secret and\n// revocable without a redeploy.\n//\n// To fan the same events out to YOUR OWN https endpoint instead, subscribe:\n// POST /v1/webhooks/subscriptions { \"target_url\": \"\u2026\", \"event_prefixes\": [\"job.\"] }\n// Both ride the same spine; this one keeps the state inside your backend.\n\n// cron-walk: persisted-cursor \u2014 the audit watermark lives in the `__cursor__` row and advances with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 rows \u2014 bounds one tick\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype Payload = Record<string, unknown>;\ninterface Rule {\n level: Level;\n key: (p: Payload) => string;\n subject: (p: Payload) => string;\n detail: (p: Payload) => string;\n /** a healing event: clears the row rather than incrementing it */\n resolves?: boolean;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\nconst num = (v: unknown) => (typeof v === 'number' ? v : null);\n\n/** THE ALLOW-LIST. Every name below is an audit event the jobs feature writes\n * today; anything not listed is ignored. Own this table \u2014 it is data, not a\n * routing engine. `GET /v1/webhooks/events/catalog` lists every event the\n * platform can emit if you want to widen it. */\nconst RULES: Record<string, Rule> = {\n // a run exhausted its retries\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `run ${str(p.run_id)} died after attempt ${num(p.attempt) ?? '?'}`,\n },\n // the tenant burned its daily dead-letter budget \u2014 a louder, rarer signal\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n subject: (p) => str(p.job_name),\n detail: (p) => `daily dead-letter quota spent; run ${str(p.run_id)} skipped its retries`,\n },\n // a schedule fired more than 2\xD7 its own interval late\n 'jobs.schedule.missed': {\n level: 'warn',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) =>\n `expected ${str(p.expected_at)}, observed ${str(p.observed_at)} `\n + `(${num(p.late_seconds) ?? '?'}s late on a ${num(p.interval_seconds) ?? '?'}s interval)`,\n },\n // \u2026and its healing twin, sharing the SAME key, so recovery closes the row\n 'jobs.schedule.recovered': {\n level: 'info',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `back on time at ${str(p.observed_at)}`,\n resolves: true,\n },\n // a generation run ended in a terminal failure (its held credits are released)\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'unclassified')}`,\n subject: (p) => str(p.error_class, 'unclassified'),\n detail: (p) => `run ${str(p.run_id)} failed (${str(p.error_class, 'no error class')})`,\n },\n};\n\ninterface AuditRow { id?: string | number; event?: string; created_at?: string; payload?: Payload }\ninterface Item { item_id: string; version: number; data: Record<string, unknown> }\n// the schedule tick, or a hand invoke (`POST /v1/fn/incident-watch`) carrying `{ dry_run }`\ntype Env = CronFunctionEnvelope | HttpFunctionEnvelope<{ dry_run?: boolean }>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const readKey = env.secrets?.vxil_read_key;\n if (!cms || !readKey) {\n return Response.json({ error: 'missing cms scope or vxil_read_key secret' }, { status: 503 });\n }\n const store = new Store(base, cms);\n\n // 1. the watermark\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const newest = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: newest, last_seen: new Date().toISOString() });\n return Response.json({ initialized: true, cursor: newest, raised: 0 });\n }\n let cursor = String(cursorRow.data.cursor ?? '0');\n\n // 2. drain\n const rows: AuditRow[] = [];\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows: batch, next } = await drain(base, readKey, cursor);\n rows.push(...batch);\n if (batch.length > 0) cursor = String(batch[batch.length - 1]!.id ?? cursor);\n if (!next || batch.length === 0) break;\n cursor = next;\n }\n\n // 3 + 4. match and upsert\n let raised = 0;\n let resolved = 0;\n const now = new Date().toISOString();\n for (const row of rows) {\n const rule = RULES[String(row.event ?? '')];\n if (!rule) continue;\n const p = row.payload ?? {};\n const key = rule.key(p);\n const existing = await store.byKey(key);\n\n if (rule.resolves) {\n if (existing) {\n await store.patch(existing, {\n level: 'info', event: String(row.event), detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n resolved++;\n }\n continue;\n }\n if (existing) {\n await store.patch(existing, {\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: Number(existing.data.seen_count ?? 0) + 1,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n } else {\n await store.create({\n key,\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: 1,\n first_seen: now,\n last_seen: now,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n });\n }\n raised++;\n }\n\n // 5. advance \u2014 If-Match, so exactly one overlapping run wins\n if (!env.payload?.dry_run) {\n await store.patch(cursorRow, { cursor, last_seen: now });\n }\n return Response.json({ scanned: rows.length, raised, resolved, cursor });\n },\n};\n\n// \u2500\u2500 the audit stream (ascending NDJSON; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function drain(\n base: string, key: string, afterId: string,\n): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(\n `${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`,\n { headers: { authorization: `Bearer ${key}` } },\n );\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms store (the REST envelope is { data: { items: [{ item_id, version, data }] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() {\n return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' };\n }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/incidents?filter=${filter}&limit=1`, {\n headers: this.h(),\n });\n if (!res.ok) return null;\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 \u2014 a concurrent run already claimed this unique key */\n async create(data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n /** false on 409 \u2014 a concurrent run already moved this row past `version` */\n async patch(item: Item, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents/${item.item_id}`, {\n method: 'PATCH',\n headers: { ...this.h(), 'if-match': String(item.version) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n",
18461
18113
  "process-batch.ts": "// process-batch.ts \u2014 THE WORKER (a vxil function).\n//\n// Trigger: queue. This function has no URL a browser can reach \u2014 it runs because\n// something was ENQUEUED. The envelope it receives is:\n// { trigger: 'queue', tenant_id, request_id, idempotency_key, vxil_base,\n// scoped_jwts, secrets, payload }\n// where `payload` is exactly the object you put inside the enqueue body's\n// `payload.payload`, and `idempotency_key` is stable per run.\n//\n// DELIVERY IS AT-LEAST-ONCE. A retry, an overlapping tick, or a replay can hand\n// you the same batch twice, so correctness cannot rest on \"it runs once\". Here\n// the `request_key` field on `renders` is declared `unique`, which turns the\n// second create into a clean 409 \u2014 and a 409 is not an error, it is the answer\n// \"already done\". That is the whole dedupe strategy: one declared field, and a\n// status code you agree to read as success.\n\nimport type { QueueFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = QueueFunctionEnvelope<{ batch_id?: string; items?: Array<{ key?: string; title?: string; credits?: number }> }>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n const batchId = String(env.payload?.batch_id ?? env.idempotency_key ?? 'batch');\n const items = Array.isArray(env.payload?.items) ? env.payload!.items! : [];\n if (items.length === 0) return Response.json({ batch_id: batchId, created: 0, duplicates: 0 });\n\n let created = 0;\n let duplicates = 0;\n let rejected = 0;\n\n for (const [i, item] of items.entries()) {\n // Derive a stable key per item so the SAME batch always produces the SAME\n // keys \u2014 that is what makes the redelivery a duplicate rather than a copy.\n const requestKey = String(item.key ?? `${batchId}:${i}`);\n const res = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: {\n request_key: requestKey,\n title: String(item.title ?? requestKey),\n state: 'queued',\n run_id: env.idempotency_key ?? null,\n credits: typeof item.credits === 'number' ? item.credits : 0,\n created_at: new Date().toISOString(),\n },\n }),\n });\n if (res.ok) created++;\n else if (res.status === 409) duplicates++; // already processed \u2014 the point of `unique`\n else rejected++;\n }\n\n return Response.json({ batch_id: batchId, created, duplicates, rejected });\n },\n};\n"
@@ -18517,7 +18169,7 @@ export default defineConfig({
18517
18169
  collections: {
18518
18170
  topics: {
18519
18171
  singular: 'topic',
18520
- // PUBLIC DELIVERY (cms.md \xA716): a public research library \u2014 published topics
18172
+ // PUBLIC DELIVERY (guide ch. 4, public delivery): a public research library \u2014 published topics
18521
18173
  // read with NO API key over GET /v1/cms/public/:tenantId/topics.
18522
18174
  public: true,
18523
18175
  fields: {
@@ -18528,7 +18180,7 @@ export default defineConfig({
18528
18180
  },
18529
18181
  sources: {
18530
18182
  singular: 'source',
18531
- // PUBLIC DELIVERY (cms.md \xA716): the public citation index \u2014 published sources
18183
+ // PUBLIC DELIVERY (guide ch. 4, public delivery): the public citation index \u2014 published sources
18532
18184
  // read keyless, edge-cached; drafts (works-in-progress) are never served.
18533
18185
  public: true,
18534
18186
  fields: {
@@ -18543,7 +18195,7 @@ export default defineConfig({
18543
18195
  },
18544
18196
  notes: {
18545
18197
  singular: 'note',
18546
- // PUBLIC DELIVERY (cms.md \xA716): published notes are the reader-facing library
18198
+ // PUBLIC DELIVERY (guide ch. 4, public delivery): published notes are the reader-facing library
18547
18199
  // pages \u2014 keyless over GET /v1/cms/public/:tenantId/notes; keep a note DRAFT
18548
18200
  // while writing and it stays private until you publish it.
18549
18201
  public: true,
@@ -18551,7 +18203,7 @@ export default defineConfig({
18551
18203
  title: { type: 'string', required: true, indexSlot: 's1' },
18552
18204
  body: { type: 'text' },
18553
18205
  // Slot the relations so notes can be filtered by their source/topic
18554
- // (the \xA712.1 single-hop dotted-key join over a slot-bound relation).
18206
+ // (the single-hop dotted-key join (guide ch. 4, relational depth) over a slot-bound relation).
18555
18207
  source: { type: 'relation', relationTo: 'sources', indexSlot: 's2' },
18556
18208
  topic: { type: 'relation', relationTo: 'topics', indexSlot: 's3' },
18557
18209
  tags: { type: 'json' }, // ["method","open-question"] \u2014 $arrayContains-searchable
@@ -18573,7 +18225,7 @@ export default defineConfig({
18573
18225
  },
18574
18226
  });
18575
18227
  `,
18576
- "readme": '# Research Library template\n\nA knowledge base for research/reference content \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions (all `cms` collections you own and can edit \u2014 all `public: true`):**\n- `topics` \u2014 subject areas (name, slug, description).\n- `sources` \u2014 cited works (title, url, author, kind, year \u2192 `topics`); a Lane-A hook keeps the year sane.\n- `notes` \u2014 your writing, cross-linked to a `source` and a `topic` (slot-bound so you can filter notes by either \u2014 the single-hop join in vxil.com/docs/guide/04-data-with-cms). `tags` is a JSON array (`$arrayContains`-searchable).\n\nAll three are **`public: true`** \u2014 a published research library reads over the keyless public-delivery lane\n(see below); keep a note or source as a DRAFT while it is a work-in-progress and it stays private.\n\n**Use it:**\n\n```bash\nvxil init --template research-library\nvxil quickstart # a new backend (or `vxil link <slug>` for an existing one)\nvxil push # apply the collections + hooks\nvxil gen # typed SDK + MCP catalog\n```\n\n**What to learn from this:**\n- **A bounded citation graph** \u2014 `notes.source`/`notes.topic` are slot-bound relations, so a note list can\n filter one hop into the target collection: the \xA712.1 single-hop dotted-key join.\n- **Lane-A validate as a tenant-owned invariant** \u2014 the "sane year" rule is an AST-checked safe expression in\n YOUR config, run inside the write transaction (vxil.com/docs/guide/07-validation-and-hooks).\n- **`json` tags** \u2014 unslotted but `$arrayContains`-searchable \u2014 the platform indexes it for you (\xA73).\n\n```bash\n# every note whose cited source is a book, with a server key \u2014 the single-hop dotted join (\xA712.1)\ncurl -G "https://api.vxil.com/v1/cms/items/notes" \\\n -H "Authorization: Bearer $VXIL_API_KEY" \\\n --data-urlencode \'filter={"source.kind":"book"}\'\n```\n\n**The public library \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because every collection is `public: true`, a\nreader front-end serves the published library with **no API key** \u2014 the edge forces `status = \'published\'`\n(drafts stay private), edge-caches the response, and serves the safe query subset (`filter`/`sort`/`limit`).\n\n```bash\n# the public citation index \u2014 NO api key\ncurl "https://api.vxil.com/v1/cms/public/$TENANT/sources?filter=%7B%22kind%22%3A%22book%22%7D&limit=50"\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (queries \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7 `templates/docs-site/`\n(a pure public-content site) \xB7 `templates/blog/` \xB7 `templates/notes/`.\n\n**Own the shape.** After `init` the config is yours \u2014 add fields, edit the hook, add collections. Nothing is\nlocked; the "sane year" invariant rides as a tenant-owned Lane-A `validate` hook you can see and change (the\nrequired titles are plain `required: true` field flags). To narrow the public surface, drop `public: true`\nfrom any collection you want to keep behind an API key \u2014 the flag is per-collection.\n',
18228
+ "readme": '# Research Library template\n\nA knowledge base for research/reference content \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions (all `cms` collections you own and can edit \u2014 all `public: true`):**\n- `topics` \u2014 subject areas (name, slug, description).\n- `sources` \u2014 cited works (title, url, author, kind, year \u2192 `topics`); a Lane-A hook keeps the year sane.\n- `notes` \u2014 your writing, cross-linked to a `source` and a `topic` (slot-bound so you can filter notes by either \u2014 the single-hop join in vxil.com/docs/guide/04-data-with-cms). `tags` is a JSON array (`$arrayContains`-searchable).\n\nAll three are **`public: true`** \u2014 a published research library reads over the keyless public-delivery lane\n(see below); keep a note or source as a DRAFT while it is a work-in-progress and it stays private.\n\n**Use it:**\n\n```bash\nvxil init --template research-library\nvxil quickstart --env staging # a new backend (or `vxil link <slug>` for an existing one)\nvxil push # apply the collections + hooks\nvxil gen # typed SDK + MCP catalog\n```\n\n**What to learn from this:**\n- **A bounded citation graph** \u2014 `notes.source`/`notes.topic` are slot-bound relations, so a note list can\n filter one hop into the target collection: the single-hop dotted-key join (guide ch. 4, relational depth).\n- **Lane-A validate as a tenant-owned invariant** \u2014 the "sane year" rule is an AST-checked safe expression in\n YOUR config, run inside the write transaction (vxil.com/docs/guide/07-validation-and-hooks).\n- **`json` tags** \u2014 unslotted but `$arrayContains`-searchable \u2014 the platform indexes it for you (guide ch. 4, querying).\n\n```bash\n# every note whose cited source is a book, with a server key \u2014 the single-hop dotted join (guide ch. 4)\ncurl -G "https://api.vxil.com/v1/cms/items/notes" \\\n -H "Authorization: Bearer $VXIL_API_KEY" \\\n --data-urlencode \'filter={"source.kind":"book"}\'\n```\n\n**The public library \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because every collection is `public: true`, a\nreader front-end serves the published library with **no API key** \u2014 the edge forces `status = \'published\'`\n(drafts stay private), edge-caches the response, and serves the safe query subset (`filter`/`sort`/`limit`).\n\n```bash\n# the public citation index \u2014 NO api key\ncurl "https://api.vxil.com/v1/cms/public/$TENANT/sources?filter=%7B%22kind%22%3A%22book%22%7D&limit=50"\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (queries \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7 `templates/docs-site/`\n(a pure public-content site) \xB7 `templates/blog/` \xB7 `templates/notes/`.\n\n**Own the shape.** After `init` the config is yours \u2014 add fields, edit the hook, add collections. Nothing is\nlocked; the "sane year" invariant rides as a tenant-owned Lane-A `validate` hook you can see and change (the\nrequired titles are plain `required: true` field flags). To narrow the public surface, drop `public: true`\nfrom any collection you want to keep behind an API key \u2014 the flag is per-collection.\n',
18577
18229
  "functions": {}
18578
18230
  },
18579
18231
  {
@@ -18591,7 +18243,7 @@ export default defineConfig({
18591
18243
  ],
18592
18244
  "hasFunctions": false,
18593
18245
  "byoKeys": [],
18594
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Gallery\" \u2014 an image / media gallery (albums of photos), declared end-to-end in\n// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 albums \u2192 photos (resolved by relation)\n// \u2022 files \u2192 the actual image bytes (managed object storage + signed shared links)\n// The `file`-typed fields store a files-feature object ref; the item stays a\n// bounded JSON document. Everything here is DATA the tenant owns and edits.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: { draftPublish: true },\n files: { enabled: true }, // managed object storage + shared links for the images\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n albums: {\n singular: 'album',\n // PUBLIC DELIVERY (cms.md \xA716): a public gallery reads keyless \u2014 published\n // albums over GET /v1/cms/public/:tenantId/albums, edge-cached.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n cover: { type: 'file' }, // a files-feature object ref (the album cover)\n description: { type: 'text' },\n },\n },\n photos: {\n singular: 'photo',\n // PUBLIC DELIVERY (cms.md \xA716): published photos read with NO API key. The\n // `image`/`cover` fields carry a files OBJECT REF (obj_\u2026), not bytes \u2014 the\n // public row exposes the ref; the reader resolves the actual image through a\n // files SHARED LINK (files.md), so the stored object stays access-controlled.\n public: true,\n fields: {\n title: { type: 'string', indexSlot: 's1' },\n image: { type: 'file', required: true }, // the photo bytes (via `files`)\n album: { type: 'relation', relationTo: 'albums', indexSlot: 's2' },\n taken_at: { type: 'datetime', indexSlot: 't1' },\n width: { type: 'int', indexSlot: 'n1' },\n height: { type: 'int', indexSlot: 'n2' },\n tags: { type: 'json' }, // [\"landscape\",\"2024\"] \u2014 $arrayContains-searchable\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'albums',\n items: [\n { title: 'Travels', slug: 'travels', description: 'Photos from the road.' },\n ],\n },\n ],\n },\n});\n",
18246
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Gallery\" \u2014 an image / media gallery (albums of photos), declared end-to-end in\n// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 albums \u2192 photos (resolved by relation)\n// \u2022 files \u2192 the actual image bytes (managed object storage + signed shared links)\n// The `file`-typed fields store a files-feature object ref; the item stays a\n// bounded JSON document. Everything here is DATA the tenant owns and edits.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: { draftPublish: true },\n files: { enabled: true }, // managed object storage + shared links for the images\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n albums: {\n singular: 'album',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): a public gallery reads keyless \u2014 published\n // albums over GET /v1/cms/public/:tenantId/albums, edge-cached.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n cover: { type: 'file' }, // a files-feature object ref (the album cover)\n description: { type: 'text' },\n },\n },\n photos: {\n singular: 'photo',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): published photos read with NO API key. The\n // `image`/`cover` fields carry a files OBJECT REF (obj_\u2026), not bytes \u2014 the\n // public row exposes the ref; the reader resolves the actual image through a\n // files SHARED LINK (files.md), so the stored object stays access-controlled.\n public: true,\n fields: {\n title: { type: 'string', indexSlot: 's1' },\n image: { type: 'file', required: true }, // the photo bytes (via `files`)\n album: { type: 'relation', relationTo: 'albums', indexSlot: 's2' },\n taken_at: { type: 'datetime', indexSlot: 't1' },\n width: { type: 'int', indexSlot: 'n1' },\n height: { type: 'int', indexSlot: 'n2' },\n tags: { type: 'json' }, // [\"landscape\",\"2024\"] \u2014 $arrayContains-searchable\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'albums',\n items: [\n { title: 'Travels', slug: 'travels', description: 'Photos from the road.' },\n ],\n },\n ],\n },\n});\n",
18595
18247
  "readme": "# Gallery template\n\nAn image / media gallery \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `albums` \u2014 a titled, slugged collection with a `cover` image (a `files` object ref) and description.\n **`public: true`** \u2014 a public gallery reads keyless.\n- `photos` \u2014 a `file`-typed `image` (the bytes live in `files`), linked to an `album`, with `taken_at`,\n `width`/`height`, and JSON `tags`. The `album` relation is slot-bound so you can list a single album's photos.\n **`public: true`** \u2014 published photos read keyless; the `image` field carries a files object ref (`obj_\u2026`),\n so the reader resolves the bytes through a files **shared link** (the stored object stays access-controlled).\n\n**Use it:**\n\n```bash\nvxil init --template gallery\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **`file` fields hold refs, not bytes** \u2014 `cover`/`image` store a `files`-feature object id (`obj_\u2026`); upload\n the bytes through `files` (managed storage + signed shared links) and the cms item stays a bounded JSON document.\n- **A slot-bound relation is the album view** \u2014 `photos.album` rides `s2`, so \"this album's photos\" is one\n indexed filter (vxil.com/docs/guide/04-data-with-cms).\n- **Numeric slots buy range queries** \u2014 `width`/`height` on `n1`/`n2` make dimension filters index-served.\n\n```ts\n// one album's photos with a server key, newest first\nconst { items } = await vx.from('photos').query({\n filter: { album: albumId, $status: 'published' }, sort: '-taken_at', limit: 24,\n});\n```\n\n**The public gallery \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). `albums`/`photos` are `public: true`, so a\nstatic gallery front-end reads with **no API key** (published-only, edge-cached). The photo rows carry a\nfiles object ref, not bytes \u2014 resolve each through a files shared link (vxil.com/docs/guide/06-feature-catalog), so the stored\nobject stays access-controlled even though the metadata is public.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// one album's public photos \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'photos', { filter: { album: albumId }, sort: '-taken_at' });\n```\n\n**Go deeper:** vxil.com/docs/guide/06-feature-catalog (files: upload + shared links) \xB7 vxil.com/docs/guide/04-data-with-cms (queries \xB7 **public\ndelivery**) \xB7 `templates/docs-site/` (a pure public-content site) \xB7 `templates/blog/` \xB7 `templates/directory/`.\n\n**Own the shape.** The config is yours after `init` \u2014 add EXIF fields, a `photographer` relation, more albums.\nNothing is locked.\n",
18596
18248
  "functions": {}
18597
18249
  },
@@ -18610,8 +18262,8 @@ export default defineConfig({
18610
18262
  ],
18611
18263
  "hasFunctions": false,
18612
18264
  "byoKeys": [],
18613
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Directory\" \u2014 a listings directory (categorized listings people can submit),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 categories \u2192 listings (resolved by relation)\n// \u2022 auth \u2192 accounts, so a submitter is a verified end-user\n// `listings.submitted_by` is the end-user OWNER field: in end-user mode a\n// submitter can only edit their OWN listing (a no-op for server callers). This\n// is one declarative flag, not a per-user policy. Everything here is DATA the\n// tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // submissions land as drafts; an admin publishes them\n hooks: {\n // Every listing needs a name \u2014 a pure function of the row (Lane-A validate).\n listing_name: {\n collection: 'listings',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.name) > 0',\n message: 'a listing needs a name',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // submitters sign in as end-users\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n categories: {\n singular: 'category',\n // PUBLIC DELIVERY (cms.md \xA716): the category nav reads keyless too.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n listings: {\n singular: 'listing',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified submitter may\n // only read/edit their OWN listing. Server callers are unaffected.\n ownerField: 'submitted_by',\n // PUBLIC DELIVERY (cms.md \xA716): the PUBLIC BROWSE VIEW \u2014 published listings\n // read with NO API key over GET /v1/cms/public/:tenantId/listings, and the\n // `submitted_by` owner field is STRIPPED from every served row (a visitor\n // never sees who submitted it). Owner-scoping (above) still governs the\n // authed edit lane \u2014 the two lanes are independent by design.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's3' },\n submitted_by: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id\n description: { type: 'text' },\n website: { type: 'string' }, // stored, not indexed\n location: { type: 'string' }, // stored, not indexed\n featured: { type: 'bool' },\n rating: { type: 'float', indexSlot: 'n1' },\n submitted_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'categories',\n items: [\n { name: 'Restaurants', slug: 'restaurants' },\n { name: 'Services', slug: 'services' },\n ],\n },\n ],\n },\n});\n",
18614
- "readme": "# Listings Directory template\n\nA categorized listings directory (people submit entries) \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name, unique slug. **`public: true`** \u2014 the category nav reads keyless.\n- `listings` \u2014 name, unique slug, `category` relation, `submitted_by` (the **owner field**), description,\n website, location, `featured`, `rating`, `submitted_at`. A Lane-A hook requires a name; `draftPublish` lets\n submissions land as drafts an admin publishes. **`public: true`** \u2014 the public browse view reads keyless,\n and `submitted_by` is stripped from every served row (a visitor never sees who submitted it).\n- `auth` (email/password) \u2014 so a submitter is a **verified end-user**.\n\n**Use it:**\n\n```bash\nvxil init --template directory\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **Owner-scoping is one declarative flag** \u2014 `ownerField: 'submitted_by'` (a real slot-bound string field):\n in end-user mode a submitter can only read/edit their **own** listing; server callers are unaffected\n (vxil.com/docs/guide/04-data-with-cms).\n- **Moderation is the lifecycle** \u2014 submissions land as drafts, an admin `publish` makes them public, and the\n public browse view is served over the keyless public-delivery lane (below).\n- **The name invariant is a Lane-A hook** \u2014 tenant-owned data you can edit after `init` (\xA77).\n\n```ts\n// the browse view with a server key: published listings in one category, best-rated first\nconst { items } = await vx.from('listings').query({\n filter: { category: categoryId, $status: 'published' }, sort: '-rating', limit: 25,\n});\n```\n\n**The public browse view \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `listings`/`categories` are\n`public: true`, the public directory reads with **no API key** \u2014 the edge forces `status = 'published'`\n(pending submissions stay private until an admin publishes), edge-caches the page, and **strips the\n`submitted_by` owner field** from every row. Owner-scoping (\xA715) still governs the authed edit lane \u2014 the\ntwo lanes are independent.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public browse view \u2014 NO api key, submitted_by stripped\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'listings', { filter: { 'category.slug': 'restaurants' }, sort: '-rating' });\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (owner-scoping \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/06-feature-catalog (auth) \xB7 `examples/feedback-board/` (a voting board with a Lane-A hook + an HTTP function) \xB7\n`templates/docs-site/` (a pure public-content site) \xB7 `templates/catalog/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
18265
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Directory\" \u2014 a listings directory (categorized listings people can submit),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 categories \u2192 listings (resolved by relation)\n// \u2022 auth \u2192 accounts, so a submitter is a verified end-user\n// `listings.submitted_by` is the end-user OWNER field: in end-user mode a\n// submitter can only edit their OWN listing (a no-op for server callers). This\n// is one declarative flag, not a per-user policy. Everything here is DATA the\n// tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // submissions land as drafts; an admin publishes them\n hooks: {\n // Every listing needs a name \u2014 a pure function of the row (Lane-A validate).\n listing_name: {\n collection: 'listings',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.name) > 0',\n message: 'a listing needs a name',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // submitters sign in as end-users\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n categories: {\n singular: 'category',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the category nav reads keyless too.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n listings: {\n singular: 'listing',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified submitter may\n // only read/edit their OWN listing. Server callers are unaffected.\n ownerField: 'submitted_by',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the PUBLIC BROWSE VIEW \u2014 published listings\n // read with NO API key over GET /v1/cms/public/:tenantId/listings, and the\n // `submitted_by` owner field is STRIPPED from every served row (a visitor\n // never sees who submitted it). Owner-scoping (above) still governs the\n // authed edit lane \u2014 the two lanes are independent by design.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's3' },\n submitted_by: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id\n description: { type: 'text' },\n website: { type: 'string' }, // stored, not indexed\n location: { type: 'string' }, // stored, not indexed\n featured: { type: 'bool' },\n rating: { type: 'float', indexSlot: 'n1' },\n submitted_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'categories',\n items: [\n { name: 'Restaurants', slug: 'restaurants' },\n { name: 'Services', slug: 'services' },\n ],\n },\n ],\n },\n});\n",
18266
+ "readme": "# Listings Directory template\n\nA categorized listings directory (people submit entries) \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name, unique slug. **`public: true`** \u2014 the category nav reads keyless.\n- `listings` \u2014 name, unique slug, `category` relation, `submitted_by` (the **owner field**), description,\n website, location, `featured`, `rating`, `submitted_at`. A Lane-A hook requires a name; `draftPublish` lets\n submissions land as drafts an admin publishes. **`public: true`** \u2014 the public browse view reads keyless,\n and `submitted_by` is stripped from every served row (a visitor never sees who submitted it).\n- `auth` (email/password) \u2014 so a submitter is a **verified end-user**.\n\n**Use it:**\n\n```bash\nvxil init --template directory\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **Owner-scoping is one declarative flag** \u2014 `ownerField: 'submitted_by'` (a real slot-bound string field):\n in end-user mode a submitter can only read/edit their **own** listing; server callers are unaffected\n (vxil.com/docs/guide/04-data-with-cms).\n- **Moderation is the lifecycle** \u2014 submissions land as drafts, an admin `publish` makes them public, and the\n public browse view is served over the keyless public-delivery lane (below).\n- **The name invariant is a Lane-A hook** \u2014 tenant-owned data you can edit after `init` (guide ch. 7).\n\n```ts\n// the browse view with a server key: published listings in one category, best-rated first\nconst { items } = await vx.from('listings').query({\n filter: { category: categoryId, $status: 'published' }, sort: '-rating', limit: 25,\n});\n```\n\n**The public browse view \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `listings`/`categories` are\n`public: true`, the public directory reads with **no API key** \u2014 the edge forces `status = 'published'`\n(pending submissions stay private until an admin publishes), edge-caches the page, and **strips the\n`submitted_by` owner field** from every row. Owner-scoping (guide ch. 4, ownerField) still governs the authed edit lane \u2014 the\ntwo lanes are independent.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public browse view \u2014 NO api key, submitted_by stripped\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'listings', { filter: { 'category.slug': 'restaurants' }, sort: '-rating' });\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (owner-scoping \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/06-feature-catalog (auth) \xB7 `examples/feedback-board/` (a voting board with a Lane-A hook + an HTTP function) \xB7\n`templates/docs-site/` (a pure public-content site) \xB7 `templates/catalog/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
18615
18267
  "functions": {}
18616
18268
  },
18617
18269
  {
@@ -18628,8 +18280,8 @@ export default defineConfig({
18628
18280
  ],
18629
18281
  "hasFunctions": false,
18630
18282
  "byoKeys": [],
18631
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \"Tasks\" \u2014 the minimal starter: one cms collection + notifications, declared\n// end-to-end in one typed file. The default `vxil init` scaffold. Everything\n// here is DATA you own and edit.\nexport default defineConfig({\n env: 'staging',\n\n // (a) per-feature config \u2014 the same TypeBox manifests, validated server-side.\n features: {\n cms: { draftPublish: true },\n notifications: { provider: 'mock', fromEmail: 'noreply@example.app' },\n },\n\n // (b) CMS schema-as-code \u2014 collections + fields (reconciled by `vxil push`).\n cms: {\n collections: {\n tasks: {\n singular: 'task',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n done: { type: 'bool' },\n due: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n // (c) functions \u2014 tenant code (\xA77.3, paid/opt-in). Uncomment to deploy.\n // functions: {\n // hello: { entry: './functions/hello.ts', trigger: { kind: 'http' } },\n // },\n\n // (d) seed \u2014 one row `vxil seed` applies so there's something to query.\n seed: {\n cms: [{ collection: 'tasks', items: [{ title: 'Try vxil push', done: false }] }],\n },\n});\n",
18632
- "readme": "# Tasks template (starter)\n\nThe minimal starter and the default `vxil init` scaffold \u2014 one `cms` collection (`tasks`) plus `notifications`.\n\n```bash\nvxil init # scaffolds this template by default\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The three-block config anatomy** \u2014 `features` (per-feature config, validated server-side),\n `cms.collections` (schema-as-code, reconciled by `vxil push`), and the commented-out `functions` block you\n uncomment when you need tenant code.\n- **Index slots are the query budget** \u2014 `title` (`s1`) and `due` (`t1`) are slot-bound so they filter/sort by\n range; `done` is a plain `bool` (equality-only, no slot) (vxil.com/docs/guide/04-data-with-cms).\n- **`draftPublish: true`** \u2014 every item carries the draft\u2192published lifecycle, filtered with the reserved\n `$status` key.\n\n```ts\nconst { item_id } = await vx.from('tasks').create(\n { title: 'Ship the release', due: new Date(Date.now() + 86400e3).toISOString() },\n { status: 'published' },\n);\n\n// due before this time tomorrow, soonest first (slot-indexed range filter + sort)\nconst { items } = await vx.from('tasks').query({\n filter: { due: { $lt: new Date(Date.now() + 86400e3).toISOString() } }, sort: 'due', limit: 20,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/05-typed-sdk-and-cli (the `vx.from` handle) \xB7 vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7\n`templates/notes/` (even smaller) \xB7 `templates/blog/` (relations + hooks).\n\nOwn the shape \u2014 add fields, add collections, uncomment the starter function. Nothing is locked.\n",
18283
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \"Tasks\" \u2014 the minimal starter: one cms collection + notifications, declared\n// end-to-end in one typed file. The default `vxil init` scaffold. Everything\n// here is DATA you own and edit.\nexport default defineConfig({\n env: 'staging',\n\n // (a) per-feature config \u2014 the same TypeBox manifests, validated server-side.\n features: {\n cms: { draftPublish: true },\n notifications: { provider: 'mock', fromEmail: 'noreply@example.app' },\n },\n\n // (b) CMS schema-as-code \u2014 collections + fields (reconciled by `vxil push`).\n cms: {\n collections: {\n tasks: {\n singular: 'task',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n done: { type: 'bool' },\n due: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n // (c) functions \u2014 your own code, opt-in and plan-limited (on Free: staging and\n // development projects; production projects from Developer). Uncomment to deploy.\n // functions: {\n // hello: { entry: './functions/hello.ts', trigger: { kind: 'http' } },\n // },\n\n // (d) seed \u2014 one row `vxil seed` applies so there's something to query.\n seed: {\n cms: [{ collection: 'tasks', items: [{ title: 'Try vxil push', done: false }] }],\n },\n});\n",
18284
+ "readme": "# Tasks template (starter)\n\nThe minimal starter and the default `vxil init` scaffold \u2014 one `cms` collection (`tasks`) plus `notifications`.\n\n```bash\nvxil init # scaffolds this template by default\nvxil quickstart --env staging\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The three-block config anatomy** \u2014 `features` (per-feature config, validated server-side),\n `cms.collections` (schema-as-code, reconciled by `vxil push`), and the commented-out `functions` block you\n uncomment when you need tenant code.\n- **Index slots are the query budget** \u2014 `title` (`s1`) and `due` (`t1`) are slot-bound so they filter/sort by\n range; `done` is a plain `bool` (equality-only, no slot) (vxil.com/docs/guide/04-data-with-cms).\n- **`draftPublish: true`** \u2014 every item carries the draft\u2192published lifecycle, filtered with the reserved\n `$status` key.\n\n```ts\nconst { item_id } = await vx.from('tasks').create(\n { title: 'Ship the release', due: new Date(Date.now() + 86400e3).toISOString() },\n { status: 'published' },\n);\n\n// due before this time tomorrow, soonest first (slot-indexed range filter + sort)\nconst { items } = await vx.from('tasks').query({\n filter: { due: { $lt: new Date(Date.now() + 86400e3).toISOString() } }, sort: 'due', limit: 20,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/05-typed-sdk-and-cli (the `vx.from` handle) \xB7 vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7\n`templates/notes/` (even smaller) \xB7 `templates/blog/` (relations + hooks).\n\nOwn the shape \u2014 add fields, add collections, uncomment the starter function. Nothing is locked.\n",
18633
18285
  "functions": {}
18634
18286
  },
18635
18287
  {
@@ -18646,7 +18298,7 @@ export default defineConfig({
18646
18298
  "hasFunctions": false,
18647
18299
  "byoKeys": [],
18648
18300
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \"Notes\" \u2014 a single-collection notes app (no draft/publish), declared end-to-end\n// in one typed file. A minimal starter. Everything here is DATA you own and edit.\nexport default defineConfig({\n env: 'staging',\n features: { cms: { draftPublish: false } },\n cms: {\n collections: {\n notes: {\n singular: 'note',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n body: { type: 'text' },\n pinned: { type: 'bool' },\n },\n },\n },\n },\n // One row of seed data `vxil seed` applies so there's something to query.\n seed: {\n cms: [{ collection: 'notes', items: [{ title: 'Welcome', body: 'This backend was declared in one typed file.', pinned: true }] }],\n },\n});\n",
18649
- "readme": "# Notes template (starter)\n\nThe simplest possible backend \u2014 one `cms` collection (`notes`), no draft/publish.\n\n```bash\nvxil init --template notes\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **How small a backend can be** \u2014 one feature, one collection, three fields; every config leaf omitted here\n falls back to a validated server-side default.\n- **`draftPublish: false`** \u2014 no editorial lifecycle: writes are live immediately (compare `templates/blog`).\n- **Slot only what you query** \u2014 `title` (`s1`) is slot-bound for search and sort; `body` (`text`) and `pinned`\n (`bool`) take no slot (`text`/`bool`/`json` aren't slottable) \u2014 equality on any field is still index-served\n (vxil.com/docs/guide/04-data-with-cms).\n\n```ts\nawait vx.from('notes').create({ title: 'Meeting notes', body: 'Decisions\u2026', pinned: true });\n\n// slotted string fields take $contains (index-served substring search)\nconst { items } = await vx.from('notes').query({\n filter: { title: { $contains: 'meeting' } }, limit: 20,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7 vxil.com/docs/guide/05-typed-sdk-and-cli \xB7\n`templates/tasks/` (the default scaffold) \xB7 `templates/research-library/` (notes grown up).\n\nOwn the shape \u2014 nothing is locked.\n",
18301
+ "readme": "# Notes template (starter)\n\nThe simplest possible backend \u2014 one `cms` collection (`notes`), no draft/publish.\n\n```bash\nvxil init --template notes\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **How small a backend can be** \u2014 one feature, one collection, three fields; every config leaf omitted here\n falls back to a validated server-side default.\n- **`draftPublish: false`** \u2014 no editorial lifecycle: writes are live immediately (compare `templates/blog`).\n- **Slot only what you query** \u2014 `title` (`s1`) is slot-bound for search and sort; `body` (`text`) and `pinned`\n (`bool`) take no slot (`text`/`bool`/`json` aren't slottable) \u2014 equality on an unslotted field still works,\n as a bounded scan; mark a field `indexed: true` (up to 4 per collection) to make its `=` / `$in` filters\n index-served (vxil.com/docs/guide/04-data-with-cms#indexed-fields-fast-equality-without-a-slot).\n\n```ts\nawait vx.from('notes').create({ title: 'Meeting notes', body: 'Decisions\u2026', pinned: true });\n\n// $contains is a substring match \u2014 a bounded scan of the collection (pair it with an indexed term to narrow it)\nconst { items } = await vx.from('notes').query({\n filter: { title: { $contains: 'meeting' } }, limit: 20,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7 vxil.com/docs/guide/05-typed-sdk-and-cli \xB7\n`templates/tasks/` (the default scaffold) \xB7 `templates/research-library/` (notes grown up).\n\nOwn the shape \u2014 nothing is locked.\n",
18650
18302
  "functions": {}
18651
18303
  }
18652
18304
  ];
@@ -18951,7 +18603,7 @@ var CsvJsonAdapter = class {
18951
18603
  yield* this.paged(rows, cursor);
18952
18604
  }
18953
18605
  /** identity rows from the _schema.json authTable, password-ish columns
18954
- * STRIPPED (hash values never leave the export dir — design §9 PII rule). */
18606
+ * STRIPPED (hash values never leave the export dir — the PII rule). */
18955
18607
  async *readAuthUsers(cursor) {
18956
18608
  const { schema, tables } = this.load();
18957
18609
  if (!schema.authUsers?.present || !schema.authUsers.table) {
@@ -19523,7 +19175,7 @@ var GenericPgAdapter = class {
19523
19175
  }
19524
19176
  }
19525
19177
  /** generic identity read: the `--auth-table` rows MINUS password/secret-ish
19526
- * columns (hash values are never read out — design §9 PII rule; the
19178
+ * columns (hash values are never read out — the PII rule; the
19527
19179
  * supabase subclass overrides with the auth.users allowlist). */
19528
19180
  async *readAuthUsers(cursor) {
19529
19181
  const source = await this.introspect();
@@ -19588,7 +19240,7 @@ var supabaseSql = {
19588
19240
  text: `SELECT count(*)::bigint AS n FROM auth.users`,
19589
19241
  params: []
19590
19242
  }),
19591
- /** W14 — the ONE statement that reads a credential: GoTrue's bcrypt
19243
+ /** The ONE statement that reads a credential: GoTrue's bcrypt
19592
19244
  * `encrypted_password` ('' for a user with no password, e.g. OAuth-only)
19593
19245
  * plus whether the address was confirmed, keyset on the uuid id. Run only
19594
19246
  * under `--with-password-hashes` / `--oidc-issuer`; soft-deleted users are
@@ -19691,7 +19343,7 @@ var SupabaseAdapter = class extends GenericPgAdapter {
19691
19343
  after = next.slice(2);
19692
19344
  }
19693
19345
  }
19694
- /** W14 credential pages (supabaseSql.credentialsPage). The rows hold bcrypt
19346
+ /** Credential pages (supabaseSql.credentialsPage). The rows hold bcrypt
19695
19347
  * hashes: never logged, never written anywhere but the import request. */
19696
19348
  async *readCredentials(cursor) {
19697
19349
  const source = await this.introspect();
@@ -21240,8 +20892,8 @@ function initProject() {
21240
20892
  console.log(` vxil.config.ts # ${tpl.summary}`);
21241
20893
  console.log(` # collections: ${tpl.collections.join(", ")} \xB7 features: ${tpl.features.join(", ")}`);
21242
20894
  const fnFiles = Object.keys(tpl.functions ?? {});
21243
- if (fnFiles.length > 0) for (const f of fnFiles) console.log(` functions/${f.padEnd(8)} # the template's function (\xA77.3)`);
21244
- else console.log(" functions/hello.ts # a starter function (\xA77.3)");
20895
+ if (fnFiles.length > 0) for (const f of fnFiles) console.log(` functions/${f.padEnd(8)} # the template's function`);
20896
+ else console.log(" functions/hello.ts # a starter function");
21245
20897
  console.log(" .vxil/ # project binding (gitignored)");
21246
20898
  if (pkgRes.action === "created" || pkgRes.action === "added") {
21247
20899
  const sdk = Object.values(tpl.functions ?? {}).some((src) => src.includes("from '@vxil/sdk'"));
@@ -23478,7 +23130,7 @@ Type ${t.tenantSlug ? `the tenant slug '${t.tenantSlug}'` : "'yes'"} to run it a
23478
23130
  queue: '{"your":"payload"}',
23479
23131
  cron: "{}"
23480
23132
  };
23481
- console.log(`vxil functions dev '${name}' \u2014 local isolate, remote edge (design \xA75.1-C)`);
23133
+ console.log(`vxil functions dev '${name}' \u2014 local isolate, remote edge`);
23482
23134
  console.log(` tenant: ${t.tenantSlug ?? "(env key)"} \xB7 edge ${t.baseUrl}`);
23483
23135
  console.log(realTenantLine);
23484
23136
  console.log(` bundle: ${def.entry} (${Math.round(first.bytes / 100) / 10} KB) \u2014 rebuilds on save`);