@kindgi/bounds 0.0.0-stage → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@kindgi/bounds",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.8",
4
+ "description": "The limits a Kindgi server puts on what a tenant sends it: pattern length and engine (V8, or linear-time RE2 when hosted), schema size and depth, template limits, and the size and idle time of bodies read from tenant-named endpoints. Set once at boot; read by the packages that compile or read tenant input.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/kindgi/kindgi-sdk.git",
9
+ "directory": "packages/bounds"
10
+ },
11
+ "homepage": "https://github.com/kindgi/kindgi-sdk/tree/main/packages/bounds#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/kindgi/kindgi-sdk/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js",
22
+ "default": "./dist/index.js"
23
+ },
24
+ "./testing": {
25
+ "types": "./dist/testing.d.ts",
26
+ "import": "./dist/testing.js",
27
+ "default": "./dist/testing.js"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src",
33
+ "README.md"
34
+ ],
35
+ "engines": {
36
+ "node": ">=22.12.0"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "provenance": true
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.10.5",
44
+ "typescript": "^5.7.3",
45
+ "vitest": "^2.1.8"
46
+ },
47
+ "dependencies": {
48
+ "re2js": "^2.8.6"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -p tsconfig.build.json",
52
+ "typecheck": "tsc --noEmit",
53
+ "test": "vitest run",
54
+ "clean": "rm -rf dist *.tsbuildinfo"
55
+ }
6
56
  }
package/src/bounds.ts ADDED
@@ -0,0 +1,192 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * The limits a Kindgi server puts on what a tenant sends it: one setting
6
+ * for the whole process, set once at boot (`configureTenantInputBounds`)
7
+ * and read where tenant input is compiled or read (`tenantInputBounds`).
8
+ *
9
+ * The runtime sets {@link HOSTED_BOUNDS} under the hosted profile, where
10
+ * one tenant's input must never stall the server for the others. Anywhere
11
+ * else (a self-hosted server, a pack's own process, a test) it's
12
+ * {@link SELF_HOSTED_BOUNDS}: today's behaviour, with generous limits on
13
+ * bodies, schemas and a render's memory.
14
+ *
15
+ * Why one setting rather than an argument: the places that compile tenant
16
+ * input are deep inside the SDK's packages (guardrails, schema, agents,
17
+ * flow, tools). One explicit call at boot is testable, and a second call
18
+ * throwing keeps it from changing while the server runs. Why not the
19
+ * environment: a library that reads `process.env` behaves differently in a
20
+ * test than in the server.
21
+ */
22
+ export interface TenantInputBounds {
23
+ /**
24
+ * The engine a tenant's pattern runs on: V8's, or RE2's, which runs in
25
+ * linear time. On RE2, a tenant's JSON Schema checks its data in linear
26
+ * time too (`@kindgi/schema`): its patterns run on RE2, its formats are
27
+ * ajv-formats' `fast` ones, and `uniqueItems` compares items by a key.
28
+ */
29
+ readonly regex: 'v8' | 're2';
30
+ /** The longest a tenant's pattern may be: its source plus its flags, in characters. */
31
+ readonly patternMaxLength: number;
32
+ /**
33
+ * The most citations a `must-cite` guardrail may require (`minCitations`).
34
+ * It counts matches up to that many, and on RE2 each match can read the
35
+ * rest of the output, so this caps what one evaluation costs.
36
+ */
37
+ readonly mustCiteMaxCitations: number;
38
+ /** The largest schema compiled: its JSON, in bytes, before it's compiled. */
39
+ readonly schemaMaxBytes: number;
40
+ /** The deepest a schema may nest, objects and arrays. */
41
+ readonly schemaMaxDepth: number;
42
+ /** Compiled schemas kept, by their JSON, the least recently used dropped first. */
43
+ readonly schemaCacheEntries: number;
44
+ /**
45
+ * The most schema JSON those compiled schemas may add up to, in bytes: a
46
+ * compiled schema takes 4 to 15 times its JSON in memory for real API
47
+ * schemas, and up to about 20 times for the worst shapes measured.
48
+ */
49
+ readonly schemaCacheMaxBytes: number;
50
+ /**
51
+ * The time a tenant's schemas may spend compiling in any 60 seconds, in
52
+ * milliseconds: the compiles that count (`runAsTenant` says whose work
53
+ * it is, and which count). Past it, the tenant's next compile that
54
+ * counts is refused until the window moves on; schemas already compiled
55
+ * keep working.
56
+ */
57
+ readonly schemaCompileBudgetMs: number;
58
+ /**
59
+ * The time a tenant's templates may spend rendering in any 60 seconds,
60
+ * in milliseconds, for renders done as its work (`runAsTenant`). Past it,
61
+ * the tenant's next render is refused until the window moves on. A
62
+ * render holds the event loop while it runs.
63
+ */
64
+ readonly templateRenderBudgetMs: number;
65
+ /** A template's limits (liquidjs's own). */
66
+ readonly liquid: {
67
+ /** The longest template parsed, in characters. */
68
+ readonly parseLimit: number;
69
+ /** The longest one render may take, in milliseconds. */
70
+ readonly renderLimitMs: number;
71
+ /**
72
+ * The most one render may allocate, in liquidjs's own units (roughly
73
+ * characters of strings and arrays it builds), not bytes: 8,388,608 is
74
+ * not 8 MiB, and isn't meant to be.
75
+ */
76
+ readonly memoryLimit: number;
77
+ };
78
+ /** The largest body read from a tenant-named endpoint, decompressed, in bytes. */
79
+ readonly responseMaxBytes: number;
80
+ /** The longest wait between two chunks of such a body, in milliseconds. */
81
+ readonly responseIdleMs: number;
82
+ /** The largest one event of a `text/event-stream` may be; the stream itself has no total. */
83
+ readonly sseEventMaxBytes: number;
84
+ /** The largest request body the API reads, in bytes (routes that take more set their own). */
85
+ readonly requestMaxBytes: number;
86
+ /**
87
+ * The deepest a request's JSON may nest, in levels of objects and arrays,
88
+ * the body's own being 1. It leaves room for a schema as deep as
89
+ * `schemaMaxDepth` inside the body that carries it.
90
+ */
91
+ readonly requestJsonMaxDepth: number;
92
+ }
93
+
94
+ const KiB = 1024;
95
+ const MiB = 1024 * KiB;
96
+
97
+ /*
98
+ * The numbers: fitted to limits the code already enforces (a pack
99
+ * service's body and an HTTP tool's response 10 MiB, a webhook body
100
+ * 256 KiB, MCP sign-in 64 KiB), until hosted usage tunes them. The
101
+ * compile cache's bytes and the compile budget are measured: a 256 KiB
102
+ * schema compiles to 4–5 MiB of heap in up to 0.75 s, and a 100 KiB one
103
+ * derived from a public OpenAPI document in 0.1–0.2 s.
104
+ */
105
+
106
+ /**
107
+ * Anywhere but the hosted profile: today's behaviour. A pattern on V8's
108
+ * engine, a template's length and render time, and idle time without
109
+ * limits; caps where today has none that a self-hoster could still hurt
110
+ * itself without (bodies, schemas, the compile cache, a render's memory).
111
+ */
112
+ export const SELF_HOSTED_BOUNDS: TenantInputBounds = deepFreeze({
113
+ regex: 'v8',
114
+ patternMaxLength: 8_192,
115
+ mustCiteMaxCitations: Number.POSITIVE_INFINITY,
116
+ schemaMaxBytes: 1 * MiB,
117
+ schemaMaxDepth: 64,
118
+ schemaCacheEntries: 1_000,
119
+ schemaCacheMaxBytes: Number.POSITIVE_INFINITY,
120
+ schemaCompileBudgetMs: Number.POSITIVE_INFINITY,
121
+ templateRenderBudgetMs: Number.POSITIVE_INFINITY,
122
+ liquid: {
123
+ parseLimit: Number.POSITIVE_INFINITY,
124
+ renderLimitMs: Number.POSITIVE_INFINITY,
125
+ memoryLimit: 16_777_216,
126
+ },
127
+ responseMaxBytes: 10 * MiB,
128
+ responseIdleMs: Number.POSITIVE_INFINITY,
129
+ sseEventMaxBytes: 10 * MiB,
130
+ requestMaxBytes: 10 * MiB,
131
+ requestJsonMaxDepth: 128,
132
+ });
133
+
134
+ /** The hosted profile: patterns on RE2 (linear time), and tight limits. */
135
+ export const HOSTED_BOUNDS: TenantInputBounds = deepFreeze({
136
+ regex: 're2',
137
+ patternMaxLength: 1_024,
138
+ mustCiteMaxCitations: 100,
139
+ schemaMaxBytes: 256 * KiB,
140
+ schemaMaxDepth: 64,
141
+ schemaCacheEntries: 512,
142
+ schemaCacheMaxBytes: 8 * MiB,
143
+ schemaCompileBudgetMs: 2_000,
144
+ templateRenderBudgetMs: 5_000,
145
+ liquid: {
146
+ parseLimit: 262_144,
147
+ renderLimitMs: 1_000,
148
+ memoryLimit: 8_388_608,
149
+ },
150
+ responseMaxBytes: 10 * MiB,
151
+ responseIdleMs: 60_000,
152
+ sseEventMaxBytes: 10 * MiB,
153
+ requestMaxBytes: 2 * MiB,
154
+ requestJsonMaxDepth: 128,
155
+ });
156
+
157
+ let configured: TenantInputBounds | undefined;
158
+
159
+ /**
160
+ * Sets the process's bounds, once, at boot. A second call throws: they
161
+ * never change while the server runs.
162
+ */
163
+ export function configureTenantInputBounds(bounds: TenantInputBounds): void {
164
+ if (configured !== undefined) {
165
+ throw new Error('Tenant input bounds are already set: they are set once, at boot.');
166
+ }
167
+ configured = deepFreeze(structuredClone(bounds));
168
+ }
169
+
170
+ /** The process's bounds: what boot set, or {@link SELF_HOSTED_BOUNDS}. */
171
+ export function tenantInputBounds(): TenantInputBounds {
172
+ return configured ?? SELF_HOSTED_BOUNDS;
173
+ }
174
+
175
+ /**
176
+ * For tests (`@kindgi/bounds/testing`): runs with other bounds, and puts
177
+ * back what was set.
178
+ */
179
+ export function overrideTenantInputBounds(bounds: TenantInputBounds): () => void {
180
+ const before = configured;
181
+ configured = deepFreeze(structuredClone(bounds));
182
+ return () => {
183
+ configured = before;
184
+ };
185
+ }
186
+
187
+ function deepFreeze<T extends object>(value: T): T {
188
+ for (const inner of Object.values(value)) {
189
+ if (inner !== null && typeof inner === 'object') deepFreeze(inner);
190
+ }
191
+ return Object.freeze(value);
192
+ }
package/src/budget.ts ADDED
@@ -0,0 +1,181 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Budgets kept per tenant: the time a tenant's work of one kind (its
6
+ * schema compiles, its template renders) takes in any 60 seconds. That
7
+ * work holds the event loop while it runs, so a tenant past its budget
8
+ * waits instead of the others: its next piece of that work is refused
9
+ * until the window moves on, and may be retried then. The budget doesn't
10
+ * stop a piece it let start, so a tenant's minute can run past the budget
11
+ * by one piece.
12
+ */
13
+
14
+ /** The window a budget kept per tenant counts over, in milliseconds. */
15
+ export const BUDGET_WINDOW_MS = 60_000;
16
+
17
+ /** Which budget refused: what the API's refusal names in `details.limit`. */
18
+ export type TenantBudgetLimit = 'schema-compile-time' | 'template-render-time';
19
+
20
+ /**
21
+ * A tenant's work refused: it spent its budget of `budgetMs` (`limit`) in
22
+ * the last 60 seconds, and is under it again after `retryAfterMs`. Every
23
+ * budget's refusal is one of these, so what answers for one (the API, an
24
+ * agent's turn, a run's step) answers for them all.
25
+ */
26
+ export class TenantBudgetError extends Error {
27
+ constructor(
28
+ message: string,
29
+ readonly limit: TenantBudgetLimit,
30
+ readonly budgetMs: number,
31
+ readonly retryAfterMs: number,
32
+ ) {
33
+ super(message);
34
+ this.name = 'TenantBudgetError';
35
+ }
36
+ }
37
+
38
+ /** Where a budget reads the time. */
39
+ export interface BudgetClock {
40
+ /** The wall time, in milliseconds: where a piece of work sits in the window. */
41
+ now(): number;
42
+ /** A monotonic time, in milliseconds: how long a piece of work took. */
43
+ monotonic(): number;
44
+ }
45
+
46
+ /** The system's clock, read when asked: `Date.now` and `performance.now`. */
47
+ export const SYSTEM_CLOCK: BudgetClock = {
48
+ now: () => Date.now(),
49
+ monotonic: () => performance.now(),
50
+ };
51
+
52
+ interface Spent {
53
+ /** When the piece of work started, by the wall clock. */
54
+ readonly at: number;
55
+ /** How long it took, in whole microseconds. */
56
+ readonly us: number;
57
+ }
58
+
59
+ /**
60
+ * One tenant's work in the window: its pieces in the order they started,
61
+ * from `head` on, and their total. Whole microseconds, so adding pieces
62
+ * and dropping them never drifts from what they add up to.
63
+ */
64
+ interface Ledger {
65
+ pieces: Spent[];
66
+ head: number;
67
+ spentUs: number;
68
+ /**
69
+ * Once refused: when the tenant is under its budget again. Read only
70
+ * while it's over the budget; a piece charged clears it.
71
+ */
72
+ refusedUntil: number;
73
+ }
74
+
75
+ /**
76
+ * The time each tenant spent on one kind of work in the last 60 seconds,
77
+ * against `budgetMs`; `refuse` makes the error for a tenant past it. An
78
+ * infinite budget keeps no count. Charging a piece costs the same however
79
+ * many pieces the window holds.
80
+ */
81
+ export class TenantTimeBudget {
82
+ private readonly ledgers = new Map<string, Ledger>();
83
+ private readonly budgetUs: number;
84
+ private sweptAt = 0;
85
+
86
+ constructor(
87
+ private readonly budgetMs: number,
88
+ private readonly refuse: (budgetMs: number, retryAfterMs: number) => TenantBudgetError,
89
+ private readonly clock: BudgetClock = SYSTEM_CLOCK,
90
+ ) {
91
+ this.budgetUs = budgetMs * 1_000;
92
+ }
93
+
94
+ /**
95
+ * Runs `work` as `tenant`'s, and returns what it returns: refused with
96
+ * `refuse`'s error, before it starts, once the tenant has spent its
97
+ * budget. Its time is charged, a failed piece's too.
98
+ */
99
+ charge<T>(tenant: string, work: () => T): T {
100
+ if (this.budgetMs === Number.POSITIVE_INFINITY) return work();
101
+ const now = this.clock.now();
102
+ this.sweep(now);
103
+ this.check(tenant, now);
104
+ const started = this.clock.monotonic();
105
+ try {
106
+ return work();
107
+ } finally {
108
+ this.spend(tenant, now, this.clock.monotonic() - started);
109
+ }
110
+ }
111
+
112
+ /** Once a window: drops the work that left it, and the tenants with none left. */
113
+ private sweep(now: number): void {
114
+ if (now - this.sweptAt <= BUDGET_WINDOW_MS) return;
115
+ this.sweptAt = now;
116
+ for (const [tenant, ledger] of this.ledgers) {
117
+ if (this.inWindow(ledger, now) === 0) this.ledgers.delete(tenant);
118
+ }
119
+ }
120
+
121
+ /** Throws `refuse`'s error when `tenant` has spent its budget. */
122
+ private check(tenant: string, now: number): void {
123
+ const ledger = this.ledgerOf(tenant);
124
+ this.inWindow(ledger, now);
125
+ if (ledger.spentUs < this.budgetUs) return;
126
+ // Read after the count, so a wall clock set back never refuses a tenant under its budget.
127
+ if (now < ledger.refusedUntil) throw this.refuse(this.budgetMs, ledger.refusedUntil - now);
128
+ // Under the budget again once enough of the oldest work leaves the window.
129
+ let left = ledger.spentUs;
130
+ let under = now;
131
+ for (let i = ledger.head; i < ledger.pieces.length; i += 1) {
132
+ const spent = ledger.pieces[i] as Spent;
133
+ left -= spent.us;
134
+ under = spent.at + BUDGET_WINDOW_MS;
135
+ if (left < this.budgetUs) break;
136
+ }
137
+ // It holds until a piece is charged: what the tenant starts now is refused.
138
+ ledger.refusedUntil = under;
139
+ throw this.refuse(this.budgetMs, under - now);
140
+ }
141
+
142
+ /** Charges `tenant` for work started `at` that took `ms`. */
143
+ private spend(tenant: string, at: number, ms: number): void {
144
+ const ledger = this.ledgerOf(tenant);
145
+ const us = Math.round(ms * 1_000);
146
+ ledger.spentUs += us;
147
+ // A piece that started before a refusal can end after it, and move the wait.
148
+ ledger.refusedUntil = 0;
149
+ // Work ends in the order it started, but for a piece inside another one.
150
+ let i = ledger.pieces.length;
151
+ while (i > ledger.head && (ledger.pieces[i - 1] as Spent).at > at) i -= 1;
152
+ ledger.pieces.splice(i, 0, { at, us });
153
+ }
154
+
155
+ /** Drops `ledger`'s work that left the window ending `now`; returns how many pieces are left. */
156
+ private inWindow(ledger: Ledger, now: number): number {
157
+ const { pieces } = ledger;
158
+ while (
159
+ ledger.head < pieces.length &&
160
+ (pieces[ledger.head] as Spent).at <= now - BUDGET_WINDOW_MS
161
+ ) {
162
+ ledger.spentUs -= (pieces[ledger.head] as Spent).us;
163
+ ledger.head += 1;
164
+ }
165
+ // Copied down once as many were dropped as are kept: each piece is copied about once.
166
+ if (ledger.head > 0 && ledger.head * 2 >= pieces.length) {
167
+ ledger.pieces = pieces.slice(ledger.head);
168
+ ledger.head = 0;
169
+ }
170
+ return ledger.pieces.length - ledger.head;
171
+ }
172
+
173
+ private ledgerOf(tenant: string): Ledger {
174
+ let ledger = this.ledgers.get(tenant);
175
+ if (ledger === undefined) {
176
+ ledger = { pieces: [], head: 0, spentUs: 0, refusedUntil: 0 };
177
+ this.ledgers.set(tenant, ledger);
178
+ }
179
+ return ledger;
180
+ }
181
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ export {
5
+ HOSTED_BOUNDS,
6
+ SELF_HOSTED_BOUNDS,
7
+ type TenantInputBounds,
8
+ configureTenantInputBounds,
9
+ tenantInputBounds,
10
+ } from './bounds.js';
11
+ export {
12
+ BUDGET_WINDOW_MS,
13
+ type BudgetClock,
14
+ SYSTEM_CLOCK,
15
+ TenantBudgetError,
16
+ type TenantBudgetLimit,
17
+ TenantTimeBudget,
18
+ } from './budget.js';
19
+ export {
20
+ type CompiledTenantPattern,
21
+ type TenantPattern,
22
+ compileTenantPattern,
23
+ tenantPatternSyntaxProblem,
24
+ } from './pattern.js';
25
+ export {
26
+ type RunAsTenantOptions,
27
+ type TenantCompiles,
28
+ type TenantScope,
29
+ currentTenant,
30
+ runAsTenant,
31
+ runOutsideTenant,
32
+ } from './tenant.js';