@vxil/feature-configs 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,347 @@
1
+ // DECLARED API STATE — the pure declared-vs-live planner (roadmap §4.11 P0-3,
2
+ // 2026-09-23).
3
+ //
4
+ // Three datums used to be API state OUTSIDE vxil.config — rows their own /v1
5
+ // route created, that `vxil push` never converged and a rollback never
6
+ // restored — so every tenant that wanted a reproducible second environment
7
+ // wrote an `ensure-*.ts` script and a nightly assert (the cvskit evaluation,
8
+ // §8 P1-7). They are now DECLARED in the feature's config block and CONVERGED
9
+ // by `vxil push` and `POST /v1/apply`:
10
+ //
11
+ // rate-limits.policies[] — keyed by `name` (POST/PUT/DELETE /v1/rate-limits/policies)
12
+ // webhooks.subscriptions[] — keyed by `target_url` (POST/DELETE /v1/webhooks/subscriptions)
13
+ // ai.templates[] — keyed by `template` (POST /v1/ai/templates, by content hash)
14
+ //
15
+ // ONE WRITER PER DATUM, AND THE WRITER IS THE REPOSITORY. The planner below is
16
+ // PURE (no I/O, no clock): it takes the declared list and the live rows and
17
+ // returns the exact operation set. Two executors drive it against two
18
+ // transports — the control-plane (handlers/apiState.ts: the feature's own
19
+ // routes with the caller's scopes, or its own table in-process) and the CLI
20
+ // (packages/cli/src/apiState.ts: the tenant's API key) — and a parity test pins
21
+ // that the same fixtures produce the same plan on both, the cms-schema
22
+ // precedent.
23
+ //
24
+ // THE NON-DESTRUCTIVE DEFAULT. A live row the config does NOT declare is
25
+ // LEFT IN PLACE and reported as `undeclared` — deleted only when the caller
26
+ // passes allow-destructive — so adopting a block on an existing tenant is a
27
+ // report, never a wipe. A change that can only be applied by delete+recreate
28
+ // (a policy's `key_template`; a subscription's prefix set) is likewise
29
+ // destructive-SHAPED: it changes the row's id, which a caller may have
30
+ // cached, so it is reported and applied only under the same ack.
31
+ //
32
+ // Two shapes this file deliberately does NOT cover, with the reason:
33
+ // notifications.templates — templates are CODE with per-locale `overrides`
34
+ // already in config (templates.overrides); there is no stored-template row.
35
+ // notifications.campaigns — a campaign is a lifecycle row (draft → scheduled
36
+ // → sending → …) with runtime state; declaring it would re-send.
37
+ import type { DeclaredRlPolicy, DeclaredWebhookSubscription } from './index.js';
38
+
39
+ /** The target_url path markers of the platform's function-delivery lanes
40
+ * (control-plane fnCmsHooks.ts FN_CMSHOOK_PATH / FN_AUTHHOOK_PATH /
41
+ * FN_WEBHOOK_TRIGGER_PATH — mirrored here because this package is
42
+ * typebox-only). A subscription on one of them is derived from the functions
43
+ * manifest by the deploy reconciler: never declarable, never "undeclared". */
44
+ export const FN_TRIGGER_TARGET_MARKERS = [
45
+ '/v1/internal/fn/cms-hook/',
46
+ '/v1/internal/fn/auth-hook/',
47
+ '/v1/internal/fn/trigger/',
48
+ ] as const;
49
+ export function isFnTriggerSubscriptionUrl(targetUrl: string): boolean {
50
+ return FN_TRIGGER_TARGET_MARKERS.some((m) => targetUrl.includes(m));
51
+ }
52
+
53
+ export type ApiStateDatum = 'policies' | 'subscriptions' | 'templates';
54
+
55
+ /** feature → the declared-API-state datum its config block may carry. */
56
+ export const API_STATE_DATUMS: ReadonlyArray<{ feature: string; datum: ApiStateDatum }> = [
57
+ { feature: 'rate-limits', datum: 'policies' },
58
+ { feature: 'webhooks', datum: 'subscriptions' },
59
+ { feature: 'ai', datum: 'templates' },
60
+ ];
61
+
62
+ export function apiStateDatumOf(feature: string): ApiStateDatum | null {
63
+ return API_STATE_DATUMS.find((d) => d.feature === feature)?.datum ?? null;
64
+ }
65
+
66
+ /** The declared list a validated manifest carries for its feature's datum, or
67
+ * null when the feature has no datum / the block is absent. An absent block
68
+ * means "not managed by config" (nothing converges, nothing is reported) —
69
+ * distinct from an EMPTY array, which declares "there should be none". */
70
+ export function declaredApiState(
71
+ feature: string, manifest: Record<string, unknown> | null | undefined,
72
+ ): { datum: ApiStateDatum; declared: unknown[] } | null {
73
+ const datum = apiStateDatumOf(feature);
74
+ if (!datum || !manifest) return null;
75
+ const list = manifest[datum];
76
+ if (!Array.isArray(list)) return null;
77
+ return { datum, declared: list };
78
+ }
79
+
80
+ // ── rate-limits policies ─────────────────────────────────────────────────────
81
+
82
+ /** ONE row of GET /v1/rate-limits/policies (rate-limits-v1 core.ts Policy). */
83
+ export interface LiveRlPolicy {
84
+ policy_id: string;
85
+ name: string;
86
+ key_template: string;
87
+ limit: number;
88
+ window_seconds: number;
89
+ behavior: string;
90
+ /** absent on a policy stored before the token-bucket pass → 'sliding_window' */
91
+ algorithm?: string;
92
+ }
93
+
94
+ /** The PUT /v1/rate-limits/policies/:id body the converge sends (core.ts
95
+ * UpdateBody minus `name`, which is the key). */
96
+ export interface RlPolicyPatch {
97
+ limit?: number;
98
+ window_seconds?: number;
99
+ behavior?: 'block' | 'shape';
100
+ algorithm?: 'sliding_window' | 'token_bucket';
101
+ }
102
+
103
+ export const RL_DEFAULT_BEHAVIOR = 'block' as const;
104
+ export const RL_DEFAULT_ALGORITHM = 'sliding_window' as const;
105
+
106
+ export interface RlPolicyPlan {
107
+ create: DeclaredRlPolicy[];
108
+ update: Array<{ policy_id: string; name: string; patch: RlPolicyPatch; changed: string[] }>;
109
+ /** `key_template` differs — the update route cannot change it, so the only
110
+ * fix is delete+recreate (a NEW policy_id): destructive-shaped. */
111
+ recreate: Array<{ policy_id: string; name: string; declared: DeclaredRlPolicy }>;
112
+ unchanged: string[];
113
+ /** live rows the config does not declare (left in place by default) */
114
+ undeclared: Array<{ policy_id: string; name: string }>;
115
+ /** a declared name matched by MORE THAN ONE live row — names are not a
116
+ * unique key on the route, so the converge cannot pick; nothing is touched */
117
+ ambiguous: Array<{ name: string; count: number }>;
118
+ }
119
+
120
+ export function planRlPolicies(
121
+ declared: readonly DeclaredRlPolicy[], live: readonly LiveRlPolicy[],
122
+ ): RlPolicyPlan {
123
+ const plan: RlPolicyPlan = { create: [], update: [], recreate: [], unchanged: [], undeclared: [], ambiguous: [] };
124
+ const byName = new Map<string, LiveRlPolicy[]>();
125
+ for (const row of live) byName.set(row.name, [...(byName.get(row.name) ?? []), row]);
126
+ const declaredNames = new Set(declared.map((d) => d.name));
127
+ for (const d of declared) {
128
+ const rows = byName.get(d.name) ?? [];
129
+ if (rows.length === 0) { plan.create.push(d); continue; }
130
+ if (rows.length > 1) { plan.ambiguous.push({ name: d.name, count: rows.length }); continue; }
131
+ const l = rows[0]!;
132
+ if (l.key_template !== d.key_template) { plan.recreate.push({ policy_id: l.policy_id, name: d.name, declared: d }); continue; }
133
+ const patch: RlPolicyPatch = {};
134
+ const changed: string[] = [];
135
+ if (d.limit !== undefined && d.limit !== l.limit) { patch.limit = d.limit; changed.push('limit'); }
136
+ if (d.window_seconds !== undefined && d.window_seconds !== l.window_seconds) { patch.window_seconds = d.window_seconds; changed.push('window_seconds'); }
137
+ const wantBehavior: 'block' | 'shape' = d.behavior ?? RL_DEFAULT_BEHAVIOR;
138
+ if (wantBehavior !== l.behavior) { patch.behavior = wantBehavior; changed.push('behavior'); }
139
+ const wantAlgorithm: 'sliding_window' | 'token_bucket' = d.algorithm ?? RL_DEFAULT_ALGORITHM;
140
+ if (wantAlgorithm !== (l.algorithm ?? RL_DEFAULT_ALGORITHM)) { patch.algorithm = wantAlgorithm; changed.push('algorithm'); }
141
+ if (changed.length) plan.update.push({ policy_id: l.policy_id, name: d.name, patch, changed });
142
+ else plan.unchanged.push(d.name);
143
+ }
144
+ for (const row of live) {
145
+ if (!declaredNames.has(row.name)) plan.undeclared.push({ policy_id: row.policy_id, name: row.name });
146
+ }
147
+ return plan;
148
+ }
149
+
150
+ // ── webhooks subscriptions ───────────────────────────────────────────────────
151
+
152
+ /** ONE row of GET /v1/webhooks/subscriptions (state 'deleted' never listed). */
153
+ export interface LiveWebhookSubscription {
154
+ sub_id: string;
155
+ target_url: string;
156
+ event_prefixes: string[];
157
+ state?: string;
158
+ }
159
+
160
+ export interface WebhookSubscriptionPlan {
161
+ create: DeclaredWebhookSubscription[];
162
+ /** the prefix SET differs — there is no update route, so delete+recreate (a
163
+ * NEW sub_id, and the watermark restarts at "now"): destructive-shaped. */
164
+ recreate: Array<{ sub_id: string; target_url: string; declared: DeclaredWebhookSubscription }>;
165
+ unchanged: string[];
166
+ /** live rows the config does not declare, EXCLUDING the function-delivery
167
+ * lanes (those derive from the functions manifest) — left in place by default */
168
+ undeclared: Array<{ sub_id: string; target_url: string }>;
169
+ }
170
+
171
+ function sameStringSet(a: readonly string[], b: readonly string[]): boolean {
172
+ const A = new Set(a); const B = new Set(b);
173
+ return A.size === B.size && [...A].every((x) => B.has(x));
174
+ }
175
+
176
+ export function planWebhookSubscriptions(
177
+ declared: readonly DeclaredWebhookSubscription[], live: readonly LiveWebhookSubscription[],
178
+ ): WebhookSubscriptionPlan {
179
+ const plan: WebhookSubscriptionPlan = { create: [], recreate: [], unchanged: [], undeclared: [] };
180
+ const declaredUrls = new Set(declared.map((d) => d.target_url));
181
+ const matched = new Set<string>();
182
+ for (const d of declared) {
183
+ const want = d.event_prefixes ?? [];
184
+ const rows = live.filter((l) => l.target_url === d.target_url);
185
+ if (rows.length === 0) { plan.create.push(d); continue; }
186
+ // Prefer an EXACT prefix-set match among duplicates (a hand-made row equal
187
+ // to the declaration is adopted as-is — no churn); else the first row is
188
+ // the one recreated and the rest fall through as undeclared duplicates.
189
+ const exact = rows.find((l) => sameStringSet(l.event_prefixes ?? [], want));
190
+ const chosen = exact ?? rows[0]!;
191
+ matched.add(chosen.sub_id);
192
+ if (exact) plan.unchanged.push(d.target_url);
193
+ else plan.recreate.push({ sub_id: chosen.sub_id, target_url: d.target_url, declared: d });
194
+ }
195
+ for (const l of live) {
196
+ if (matched.has(l.sub_id)) continue;
197
+ if (isFnTriggerSubscriptionUrl(l.target_url)) continue; // manifest-derived, never ours
198
+ if (declaredUrls.has(l.target_url)) {
199
+ // a duplicate live row for a declared url — surplus, not the adopted one
200
+ plan.undeclared.push({ sub_id: l.sub_id, target_url: l.target_url });
201
+ continue;
202
+ }
203
+ plan.undeclared.push({ sub_id: l.sub_id, target_url: l.target_url });
204
+ }
205
+ return plan;
206
+ }
207
+
208
+ // ── ai templates ─────────────────────────────────────────────────────────────
209
+
210
+ /** ONE row of GET /v1/ai/templates. `content_sha256` is the canonical hash of
211
+ * the LATEST version's {system,user,schema} (@vxil/runtime
212
+ * aiTemplateContentSha256), stamped by ai-v1 since 2026-09-23; absent on an
213
+ * older server. */
214
+ export interface LiveAiTemplate {
215
+ name: string;
216
+ latest_version: number;
217
+ content_sha256?: string;
218
+ }
219
+
220
+ /** A declared template with its content hash pre-computed by the caller (the
221
+ * hash is async WebCrypto; this planner stays pure/sync). */
222
+ export interface HashedDeclaredAiTemplate {
223
+ template: string;
224
+ content_sha256: string;
225
+ }
226
+
227
+ export interface AiTemplatePlan {
228
+ create: string[];
229
+ /** content differs → POST a new version (monotonic per name) */
230
+ update: string[];
231
+ unchanged: string[];
232
+ /** stored names the config does not declare — there is NO delete route for
233
+ * templates, so these are only ever reported, never removed */
234
+ undeclared: string[];
235
+ /** live rows with no `content_sha256` (an older server): the content cannot
236
+ * be compared, so each is re-put as `update` and named here */
237
+ unknownContent: string[];
238
+ }
239
+
240
+ export function planAiTemplates(
241
+ declared: readonly HashedDeclaredAiTemplate[], live: readonly LiveAiTemplate[],
242
+ ): AiTemplatePlan {
243
+ const plan: AiTemplatePlan = { create: [], update: [], unchanged: [], undeclared: [], unknownContent: [] };
244
+ const byName = new Map(live.map((l) => [l.name, l]));
245
+ const declaredNames = new Set(declared.map((d) => d.template));
246
+ for (const d of declared) {
247
+ const l = byName.get(d.template);
248
+ if (!l) { plan.create.push(d.template); continue; }
249
+ if (l.content_sha256 === undefined) { plan.unknownContent.push(d.template); plan.update.push(d.template); continue; }
250
+ if (l.content_sha256 === d.content_sha256) plan.unchanged.push(d.template);
251
+ else plan.update.push(d.template);
252
+ }
253
+ for (const l of live) if (!declaredNames.has(l.name)) plan.undeclared.push(l.name);
254
+ return plan;
255
+ }
256
+
257
+ // ── the converge SUMMARY (what `vxil push` prints, what the journal stores) ──
258
+
259
+ export interface ApiStateSummary {
260
+ datum: ApiStateDatum;
261
+ created: string[];
262
+ updated: string[];
263
+ /** destructive-shaped changes APPLIED under allow-destructive (delete+recreate) */
264
+ recreated: string[];
265
+ unchanged: string[];
266
+ /** live rows the config does not declare — left in place (or, under
267
+ * allow-destructive, listed in `deleted` instead) */
268
+ undeclared: string[];
269
+ /** undeclared rows DELETED under allow-destructive */
270
+ deleted: string[];
271
+ /** destructive-shaped changes NOT applied (need allow-destructive) */
272
+ pending_destructive: string[];
273
+ /** declared keys the converge could not resolve (duplicate live rows) */
274
+ ambiguous: string[];
275
+ /** rows a route refused (a 4xx) — the converge continues past them */
276
+ failed: Array<{ key: string; error: string }>;
277
+ }
278
+
279
+ export function emptyApiStateSummary(datum: ApiStateDatum): ApiStateSummary {
280
+ return {
281
+ datum, created: [], updated: [], recreated: [], unchanged: [], undeclared: [],
282
+ deleted: [], pending_destructive: [], ambiguous: [], failed: [],
283
+ };
284
+ }
285
+
286
+ /** The `api_state` object a config-write response carries (`PUT /v1/config/
287
+ * :feature` and its dashboard twin, dry-run included — control-plane
288
+ * handlers/apiState.ts apiStateResponseField) and the shape `vxil push` READS
289
+ * back instead of converging a second time (review 2026-09-23, finding 1: the
290
+ * server's converge is the one that ran with the key's scopes; a client-side
291
+ * repeat re-listed a KV-backed datum ~100 ms later and could double-create).
292
+ * `ok:false` + `deferred:true` = the feature worker has not seen the config
293
+ * yet (the client may converge after its own wait); `ok:false` otherwise =
294
+ * a DEFINITE, named refusal (nothing to retry); `note` = converged nothing on
295
+ * purpose (a disabled feature). */
296
+ export interface ApiStateResponseField extends ApiStateSummary {
297
+ ok: boolean;
298
+ dry_run?: boolean;
299
+ deferred?: boolean;
300
+ error?: string;
301
+ note?: string;
302
+ }
303
+
304
+ /** The note every executor reports for a DISABLED feature whose block still
305
+ * declares rows: the declaration is kept in the manifest and nothing is
306
+ * converged (a parked feature is not a propagation window — review finding 2). */
307
+ export const API_STATE_DISABLED_NOTE = 'feature disabled — declaration kept, not converged';
308
+
309
+ /** `GET /v1/rate-limits/policies` is a 100-row HARD cap with no cursor. A list
310
+ * AT the cap is not a complete live set, so planning creates against it would
311
+ * re-POST every declared name the cap hid (review finding 5). Both executors
312
+ * refuse with this message instead. */
313
+ export function rlPolicyListCapError(liveCount: number): string | null {
314
+ if (liveCount < RL_POLICY_LIST_CAP) return null;
315
+ return `the policy list is at its ${RL_POLICY_LIST_CAP}-row hard cap (no cursor) — the live set may be incomplete; delete undeclared policies before converging`;
316
+ }
317
+ export const RL_POLICY_LIST_CAP = 100;
318
+
319
+ /** `+2 created · ~1 updated · =3 unchanged · 1 undeclared (left in place)` —
320
+ * the one-line summary `vxil push` prints and the apply journal notes. */
321
+ export function formatApiStateSummary(s: ApiStateSummary): string {
322
+ const parts: string[] = [];
323
+ if (s.created.length) parts.push(`+${s.created.length} created`);
324
+ if (s.updated.length) parts.push(`~${s.updated.length} updated`);
325
+ if (s.recreated.length) parts.push(`↻${s.recreated.length} recreated`);
326
+ parts.push(`=${s.unchanged.length} unchanged`);
327
+ if (s.deleted.length) parts.push(`-${s.deleted.length} deleted`);
328
+ if (s.undeclared.length) parts.push(`${s.undeclared.length} undeclared (left in place)`);
329
+ if (s.pending_destructive.length) parts.push(`${s.pending_destructive.length} destructive-shaped NOT applied (--allow-destructive)`);
330
+ if (s.ambiguous.length) parts.push(`${s.ambiguous.length} ambiguous (duplicate live rows — not touched)`);
331
+ if (s.failed.length) parts.push(`${s.failed.length} failed`);
332
+ return parts.join(' · ');
333
+ }
334
+
335
+ /** The multi-line detail under the summary: one line per named row. */
336
+ export function formatApiStateDetail(s: ApiStateSummary): string[] {
337
+ const lines: string[] = [];
338
+ for (const k of s.created) lines.push(` + ${k}`);
339
+ for (const k of s.updated) lines.push(` ~ ${k}`);
340
+ for (const k of s.recreated) lines.push(` ↻ ${k} (recreated — new id)`);
341
+ for (const k of s.deleted) lines.push(` - ${k} (deleted: undeclared, --allow-destructive)`);
342
+ for (const k of s.undeclared) lines.push(` · ${k} — live but not declared (left in place; --allow-destructive deletes it)`);
343
+ for (const k of s.pending_destructive) lines.push(` ! ${k} — needs delete+recreate (new id); re-run with --allow-destructive`);
344
+ for (const k of s.ambiguous) lines.push(` ! ${k} — more than one live row carries this name; delete the extras first`);
345
+ for (const f of s.failed) lines.push(` ✗ ${f.key} — ${f.error}`);
346
+ return lines;
347
+ }