@vxil/feature-configs 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -149,14 +149,12 @@ export interface ApiStateSummary {
149
149
  export declare function emptyApiStateSummary(datum: ApiStateDatum): ApiStateSummary;
150
150
  /** The `api_state` object a config-write response carries (`PUT /v1/config/
151
151
  * :feature` and its dashboard twin, dry-run included — control-plane
152
- * handlers/apiState.ts apiStateResponseField) and the shape `vxil push` READS
153
- * back instead of converging a second time (review 2026-09-23, finding 1: the
154
- * server's converge is the one that ran with the key's scopes; a client-side
155
- * repeat re-listed a KV-backed datum ~100 ms later and could double-create).
156
- * `ok:false` + `deferred:true` = the feature worker has not seen the config
157
- * yet (the client may converge after its own wait); `ok:false` otherwise =
158
- * a DEFINITE, named refusal (nothing to retry); `note` = converged nothing on
159
- * purpose (a disabled feature). */
152
+ * handlers/apiState.ts apiStateResponseField) and the shape `vxil push` /
153
+ * `vxil plan` print. `ok:false` + `deferred:true` = not converged for an
154
+ * INDEFINITE reason — the feature worker has not seen the config yet, or a
155
+ * transient failure (a push asks again through POST /v1/apply after its
156
+ * wait); `ok:false` otherwise = a DEFINITE, named refusal (nothing to retry);
157
+ * `note` = converged nothing on purpose (a disabled feature). */
160
158
  export interface ApiStateResponseField extends ApiStateSummary {
161
159
  ok: boolean;
162
160
  dry_run?: boolean;
@@ -170,8 +168,8 @@ export interface ApiStateResponseField extends ApiStateSummary {
170
168
  export declare const API_STATE_DISABLED_NOTE = "feature disabled \u2014 declaration kept, not converged";
171
169
  /** `GET /v1/rate-limits/policies` is a 100-row HARD cap with no cursor. A list
172
170
  * AT the cap is not a complete live set, so planning creates against it would
173
- * re-POST every declared name the cap hid (review finding 5). Both executors
174
- * refuse with this message instead. */
171
+ * re-POST every declared name the cap hid (review finding 5). The server's
172
+ * converge and the CLI's read-only plan refuse with this message instead. */
175
173
  export declare function rlPolicyListCapError(liveCount: number): string | null;
176
174
  export declare const RL_POLICY_LIST_CAP = 100;
177
175
  /** `+2 created · ~1 updated · =3 unchanged · 1 undeclared (left in place)` —
package/dist/apiState.js CHANGED
@@ -165,8 +165,8 @@ export function emptyApiStateSummary(datum) {
165
165
  export const API_STATE_DISABLED_NOTE = 'feature disabled — declaration kept, not converged';
166
166
  /** `GET /v1/rate-limits/policies` is a 100-row HARD cap with no cursor. A list
167
167
  * AT the cap is not a complete live set, so planning creates against it would
168
- * re-POST every declared name the cap hid (review finding 5). Both executors
169
- * refuse with this message instead. */
168
+ * re-POST every declared name the cap hid (review finding 5). The server's
169
+ * converge and the CLI's read-only plan refuse with this message instead. */
170
170
  export function rlPolicyListCapError(liveCount) {
171
171
  if (liveCount < RL_POLICY_LIST_CAP)
172
172
  return null;
@@ -0,0 +1,7 @@
1
+ /** JSON.stringify with object keys sorted at every depth (arrays keep their
2
+ * order; undefined-valued keys are dropped, as JSON.stringify drops them).
3
+ * Two values with equal output are equal in CONTENT. A stored manifest comes
4
+ * back from storage with its object keys re-ordered, so every declared-vs-
5
+ * stored config compare (the server's shallowDiff, the CLI's diffManifest)
6
+ * must go through this, never through plain JSON.stringify. */
7
+ export declare function canonicalJson(v: unknown): string | undefined;
@@ -0,0 +1,16 @@
1
+ /** JSON.stringify with object keys sorted at every depth (arrays keep their
2
+ * order; undefined-valued keys are dropped, as JSON.stringify drops them).
3
+ * Two values with equal output are equal in CONTENT. A stored manifest comes
4
+ * back from storage with its object keys re-ordered, so every declared-vs-
5
+ * stored config compare (the server's shallowDiff, the CLI's diffManifest)
6
+ * must go through this, never through plain JSON.stringify. */
7
+ export function canonicalJson(v) {
8
+ if (Array.isArray(v))
9
+ return `[${v.map((x) => canonicalJson(x) ?? 'null').join(',')}]`;
10
+ if (v !== null && typeof v === 'object') {
11
+ const o = v;
12
+ const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
13
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(',')}}`;
14
+ }
15
+ return JSON.stringify(v);
16
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type Static, type TSchema } from '@sinclair/typebox';
2
2
  export * from './hooks.js';
3
3
  export * from './readmodels.js';
4
4
  export * from './apiState.js';
5
+ export * from './canonicalJson.js';
5
6
  export declare const RESERVED_CREDIT_TYPES: ReadonlySet<string>;
6
7
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
7
8
  * of which is restricted to internal platform machinery). */
package/dist/index.js CHANGED
@@ -18,6 +18,9 @@ export * from './readmodels.js';
18
18
  // declared-vs-live reconciliation, shared by the control-plane apply path and
19
19
  // the CLI (`vxil plan/diff/push`), so the two push paths can never diverge.
20
20
  export * from './apiState.js';
21
+ // Key-order-insensitive JSON: the one declared-vs-stored manifest compare
22
+ // (control-plane shallowDiff, CLI diffManifest).
23
+ export * from './canonicalJson.js';
21
24
  // TypeBox validates `format:` only for registered formats — register the ones
22
25
  // our schemas use (pragmatic RFC-lite email check; providers do the real one).
23
26
  if (!FormatRegistry.Has('email')) {
@@ -1956,8 +1959,14 @@ export function validateFeatureConfig(feature, raw) {
1956
1959
  for (const [id, agent] of Object.entries(v.agents ?? {})) {
1957
1960
  const allow = agent.actions?.allow ?? {};
1958
1961
  const guestAllow = agent.guardrails?.guestToolAllow ?? [];
1959
- if (agent.guardrails?.allowGuest === true)
1962
+ if (agent.guardrails?.allowGuest === true) {
1960
1963
  anyGuestAgent = true;
1964
+ // Guests share ONE tenant-wide bucket that spends the tenant's own AI
1965
+ // key: an unlimited (0) daily cap is never allowed alongside them.
1966
+ if (agent.guardrails.rateLimitPerUserPerDay === 0) {
1967
+ errs.push(`/agents/${id}/guardrails/rateLimitPerUserPerDay: 0 (unlimited) is not allowed with allowGuest: true — set a finite daily cap (guests share one tenant-wide bucket)`);
1968
+ }
1969
+ }
1961
1970
  if (knownMcpTools) {
1962
1971
  for (const tool of Object.keys(allow)) {
1963
1972
  if (!knownMcpTools.has(tool)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "The per-feature configuration schemas and validators behind vxil.config.ts (published for @vxil/cli and @vxil/config).",
5
5
  "license": "MIT",
6
6
  "homepage": "https://vxil.com",
package/src/apiState.ts CHANGED
@@ -14,12 +14,14 @@
14
14
  //
15
15
  // ONE WRITER PER DATUM, AND THE WRITER IS THE REPOSITORY. The planner below is
16
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.
17
+ // returns the exact operation set. ONE executor drives it: the control-plane
18
+ // (handlers/apiState.ts convergeApiState — the feature's own routes with the
19
+ // caller's scopes, or its own table in-process), after every config write and
20
+ // as the api-state step of POST /v1/apply. The CLI (packages/cli/src/
21
+ // apiState.ts) never writes these rows: `vxil push` asks the server, and
22
+ // `vxil plan` / `vxil diff` run this planner READ-ONLY when the server's dry
23
+ // run could not plan. packages/cli/src/apiState.e2e.test.ts drives the real
24
+ // CLI binary against the real convergeApiState.
23
25
  //
24
26
  // THE NON-DESTRUCTIVE DEFAULT. A live row the config does NOT declare is
25
27
  // LEFT IN PLACE and reported as `undeclared` — deleted only when the caller
@@ -285,14 +287,12 @@ export function emptyApiStateSummary(datum: ApiStateDatum): ApiStateSummary {
285
287
 
286
288
  /** The `api_state` object a config-write response carries (`PUT /v1/config/
287
289
  * :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). */
290
+ * handlers/apiState.ts apiStateResponseField) and the shape `vxil push` /
291
+ * `vxil plan` print. `ok:false` + `deferred:true` = not converged for an
292
+ * INDEFINITE reason — the feature worker has not seen the config yet, or a
293
+ * transient failure (a push asks again through POST /v1/apply after its
294
+ * wait); `ok:false` otherwise = a DEFINITE, named refusal (nothing to retry);
295
+ * `note` = converged nothing on purpose (a disabled feature). */
296
296
  export interface ApiStateResponseField extends ApiStateSummary {
297
297
  ok: boolean;
298
298
  dry_run?: boolean;
@@ -308,8 +308,8 @@ export const API_STATE_DISABLED_NOTE = 'feature disabled — declaration kept, n
308
308
 
309
309
  /** `GET /v1/rate-limits/policies` is a 100-row HARD cap with no cursor. A list
310
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. */
311
+ * re-POST every declared name the cap hid (review finding 5). The server's
312
+ * converge and the CLI's read-only plan refuse with this message instead. */
313
313
  export function rlPolicyListCapError(liveCount: number): string | null {
314
314
  if (liveCount < RL_POLICY_LIST_CAP) return null;
315
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`;
@@ -0,0 +1,15 @@
1
+ /** JSON.stringify with object keys sorted at every depth (arrays keep their
2
+ * order; undefined-valued keys are dropped, as JSON.stringify drops them).
3
+ * Two values with equal output are equal in CONTENT. A stored manifest comes
4
+ * back from storage with its object keys re-ordered, so every declared-vs-
5
+ * stored config compare (the server's shallowDiff, the CLI's diffManifest)
6
+ * must go through this, never through plain JSON.stringify. */
7
+ export function canonicalJson(v: unknown): string | undefined {
8
+ if (Array.isArray(v)) return `[${v.map((x) => canonicalJson(x) ?? 'null').join(',')}]`;
9
+ if (v !== null && typeof v === 'object') {
10
+ const o = v as Record<string, unknown>;
11
+ const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
12
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(',')}}`;
13
+ }
14
+ return JSON.stringify(v);
15
+ }
package/src/index.ts CHANGED
@@ -19,6 +19,9 @@ export * from './readmodels.js';
19
19
  // declared-vs-live reconciliation, shared by the control-plane apply path and
20
20
  // the CLI (`vxil plan/diff/push`), so the two push paths can never diverge.
21
21
  export * from './apiState.js';
22
+ // Key-order-insensitive JSON: the one declared-vs-stored manifest compare
23
+ // (control-plane shallowDiff, CLI diffManifest).
24
+ export * from './canonicalJson.js';
22
25
 
23
26
  // TypeBox validates `format:` only for registered formats — register the ones
24
27
  // our schemas use (pragmatic RFC-lite email check; providers do the real one).
@@ -2377,7 +2380,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2377
2380
  const v = withDefaults as {
2378
2381
  agents?: Record<string, {
2379
2382
  actions?: { mode?: string; allow?: Record<string, unknown> };
2380
- guardrails?: { allowGuest?: boolean; guestToolAllow?: string[] };
2383
+ guardrails?: { allowGuest?: boolean; guestToolAllow?: string[]; rateLimitPerUserPerDay?: number };
2381
2384
  }>;
2382
2385
  widget?: { requireAuth?: boolean };
2383
2386
  };
@@ -2386,7 +2389,14 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2386
2389
  for (const [id, agent] of Object.entries(v.agents ?? {})) {
2387
2390
  const allow = agent.actions?.allow ?? {};
2388
2391
  const guestAllow = agent.guardrails?.guestToolAllow ?? [];
2389
- if (agent.guardrails?.allowGuest === true) anyGuestAgent = true;
2392
+ if (agent.guardrails?.allowGuest === true) {
2393
+ anyGuestAgent = true;
2394
+ // Guests share ONE tenant-wide bucket that spends the tenant's own AI
2395
+ // key: an unlimited (0) daily cap is never allowed alongside them.
2396
+ if (agent.guardrails.rateLimitPerUserPerDay === 0) {
2397
+ errs.push(`/agents/${id}/guardrails/rateLimitPerUserPerDay: 0 (unlimited) is not allowed with allowGuest: true — set a finite daily cap (guests share one tenant-wide bucket)`);
2398
+ }
2399
+ }
2390
2400
  if (knownMcpTools) {
2391
2401
  for (const tool of Object.keys(allow)) {
2392
2402
  if (!knownMcpTools.has(tool)) {