@danceiny/gotry 0.2.0-rc.32 → 0.2.0-rc.33

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.
Files changed (89) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/capabilities/session/bridge-ledger.js +168 -67
  4. package/dist/capabilities/session/extension-bridge.js +140 -46
  5. package/dist/capabilities/session/gui-flight.js +281 -69
  6. package/dist/capabilities/session/gui-hotel.js +74 -17
  7. package/dist/capabilities/session-search.js +6 -5
  8. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  9. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  10. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  11. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  12. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  13. package/dist/scripts/bridge-ledger-tests.js +20 -18
  14. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  15. package/dist/scripts/mutation-check.js +153 -0
  16. package/dist/scripts/nightly-evidence.js +4 -111
  17. package/dist/scripts/session-tests.js +42 -8
  18. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  19. package/dist/scripts/tenant-capacity-probe.js +263 -0
  20. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  21. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  22. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  23. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  24. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  25. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  26. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  27. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  28. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  29. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  30. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  31. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  32. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  33. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  34. package/dist/scripts/tenant-state-router-tests.js +464 -0
  35. package/dist/scripts/tenant-state-test-kit.js +92 -0
  36. package/dist/scripts/tenant-state-tests.js +564 -0
  37. package/dist/src/backend/modules/booking-executor.js +61 -6
  38. package/dist/src/backend/modules/session-search.js +136 -39
  39. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  40. package/dist/src/booking-surface/dsh-planner.js +150 -19
  41. package/dist/src/booking-surface/index.js +1 -0
  42. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  43. package/dist/src/booking-surface/server.js +193 -80
  44. package/dist/src/booking-surface/startup.js +153 -0
  45. package/dist/src/booking-surface/tenancy.js +188 -0
  46. package/dist/src/index.js +31 -1
  47. package/dist/src/llm-price-table.js +148 -0
  48. package/dist/src/tenant-seam/index.js +8 -0
  49. package/dist/src/tenant-seam/llm-broker.js +508 -0
  50. package/dist/src/tenant-seam/process-governor.js +281 -0
  51. package/dist/src/tenant-seam/quotas.js +360 -0
  52. package/dist/src/tenant-seam/seam.js +92 -0
  53. package/dist/src/tenant-state/durable-store.js +158 -0
  54. package/dist/src/tenant-state/epoch-copies.js +422 -0
  55. package/dist/src/tenant-state/errors.js +108 -0
  56. package/dist/src/tenant-state/ids.js +101 -0
  57. package/dist/src/tenant-state/index.js +11 -0
  58. package/dist/src/tenant-state/lease-store.js +203 -0
  59. package/dist/src/tenant-state/tenant-lease.js +150 -0
  60. package/dist/src/tenant-state/tenant-router.js +650 -0
  61. package/extension/manifest.json +2 -2
  62. package/package.json +4 -1
  63. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  64. package/ts/capabilities/session/extension-bridge.ts +176 -65
  65. package/ts/capabilities/session/gui-flight.ts +318 -88
  66. package/ts/capabilities/session/gui-hotel.ts +72 -18
  67. package/ts/capabilities/session-search.ts +17 -5
  68. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  69. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  70. package/ts/src/booking-surface/index.ts +1 -0
  71. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  72. package/ts/src/booking-surface/server.ts +215 -84
  73. package/ts/src/booking-surface/startup.ts +155 -0
  74. package/ts/src/booking-surface/tenancy.ts +242 -0
  75. package/ts/src/index.ts +28 -2
  76. package/ts/src/llm-price-table.ts +180 -0
  77. package/ts/src/tenant-seam/index.ts +9 -0
  78. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  79. package/ts/src/tenant-seam/process-governor.ts +343 -0
  80. package/ts/src/tenant-seam/quotas.ts +433 -0
  81. package/ts/src/tenant-seam/seam.ts +142 -0
  82. package/ts/src/tenant-state/durable-store.ts +171 -0
  83. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  84. package/ts/src/tenant-state/errors.ts +128 -0
  85. package/ts/src/tenant-state/ids.ts +108 -0
  86. package/ts/src/tenant-state/index.ts +12 -0
  87. package/ts/src/tenant-state/lease-store.ts +266 -0
  88. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  89. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Quotas and metering per tenant and per organization (AaaS A2, docs/rfc/aaas-rfc.md §4.4; threats S-10 and S-11).
3
+ *
4
+ * Offline and deterministic: the clock is a number the suite moves by hand, the price table is the real frozen
5
+ * `data/llm-price-table.json` (ADR-20), and nothing touches a state root or the network.
6
+ *
7
+ * P policy: invalid limits and an unpriced model are refused at construction
8
+ * C concurrency: tenant, organization and global caps, release, the narrowest scope refuses first
9
+ * R rates: turn rate and bridge job rate in rolling windows, with an exact retry hint
10
+ * L LLM budget: measured usage priced by the frozen table, fail closed on an unpriced model, the worst-case charge
11
+ * S ledger size: per tenant and per organization
12
+ * I isolation: a hot tenant is refused alone, a refusal reserves nothing, counters hold no personal data
13
+ *
14
+ * Run: cd ts && npx tsx scripts/tenant-seam-quota-tests.ts
15
+ */
16
+ import assert from 'node:assert/strict';
17
+ import { join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { loadPriceTable } from '../src/llm-price-table.js';
20
+ import { DEFAULT_QUOTA_POLICY, deriveSeamTenant, QuotaExceededError, SeamTenantRequiredError, StaticOrganizationKeyring, TenantQuotas, } from '../src/tenant-seam/index.js';
21
+ import { InvalidTenantIdError, TenantStateError } from '../src/tenant-state/errors.js';
22
+ const TS_ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..');
23
+ const TABLE = loadPriceTable(join(TS_ROOT, 'data', 'llm-price-table.json'));
24
+ const MODEL = 'deepseek-v4-flash';
25
+ const KEYRING = new StaticOrganizationKeyring({ acme: Buffer.alloc(32, 7), globex: Buffer.alloc(32, 9) });
26
+ let passed = 0;
27
+ function check(label, assertion) {
28
+ assertion();
29
+ passed += 1;
30
+ console.log(` ok - ${label}`);
31
+ }
32
+ function tenantOf(organization, subject) {
33
+ return deriveSeamTenant({ organization, issuer: `https://idp.example/${organization}`, subject, scope: 'booking:read' }, KEYRING);
34
+ }
35
+ const POLICY = {
36
+ tenant: {
37
+ concurrentTurns: 2,
38
+ turns: { limit: 3, windowMs: 1_000 },
39
+ llm: { tokens: 1_000_000, costUsd: 0.5, windowMs: 10_000 },
40
+ bridgeJobs: { limit: 2, windowMs: 1_000 },
41
+ ledgerBytes: 1_000,
42
+ },
43
+ organization: {
44
+ concurrentTurns: 3,
45
+ turns: { limit: 5, windowMs: 1_000 },
46
+ llm: { tokens: 2_000_000, costUsd: 1, windowMs: 10_000 },
47
+ bridgeJobs: { limit: 3, windowMs: 1_000 },
48
+ ledgerBytes: 1_500,
49
+ },
50
+ globalConcurrentTurns: 4,
51
+ unprovenInvocation: { tokens: 10_000, costUsd: 0.05 },
52
+ };
53
+ let now = 0;
54
+ function quotas(policy = POLICY) {
55
+ now = 0;
56
+ return new TenantQuotas({ policy, priceTable: TABLE, model: MODEL, clock: () => now });
57
+ }
58
+ function refusal(action) {
59
+ try {
60
+ action();
61
+ }
62
+ catch (error) {
63
+ assert.ok(error instanceof QuotaExceededError, `expected a QuotaExceededError, got ${String(error)}`);
64
+ return error;
65
+ }
66
+ throw new assert.AssertionError({ message: 'expected the quota to refuse' });
67
+ }
68
+ function snapshotOf(q, tenant) {
69
+ const key = `${tenant.organization}/${tenant.ledgerTenant}`;
70
+ return q.snapshot().tenants.find((entry) => entry.key === key);
71
+ }
72
+ console.log('P policy');
73
+ check('P1 a tenant cap above its organization cap, and an organization cap above the global cap, are refused', () => {
74
+ assert.throws(() => new TenantQuotas({ policy: { ...POLICY, tenant: { ...POLICY.tenant, concurrentTurns: 4 } }, priceTable: TABLE, model: MODEL }), /tenant\.concurrentTurns must not exceed/);
75
+ assert.throws(() => new TenantQuotas({ policy: { ...POLICY, globalConcurrentTurns: 2 }, priceTable: TABLE, model: MODEL }), /organization\.concurrentTurns must not exceed/);
76
+ });
77
+ check('P2 a zero, negative, fractional or non-numeric limit is refused', () => {
78
+ for (const bad of [0, -1, 1.5, Number.NaN, '3']) {
79
+ assert.throws(() => new TenantQuotas({ policy: { ...POLICY, tenant: { ...POLICY.tenant, turns: { limit: bad, windowMs: 1_000 } } }, priceTable: TABLE, model: MODEL }), /positive integer/, `limit ${String(bad)}`);
80
+ }
81
+ assert.throws(() => new TenantQuotas({ policy: { ...POLICY, unprovenInvocation: { tokens: 1, costUsd: 0 } }, priceTable: TABLE, model: MODEL }), /positive number/);
82
+ });
83
+ check('P3 a model the frozen table does not price stops construction before any spend (ADR-20)', () => {
84
+ assert.throws(() => new TenantQuotas({ policy: POLICY, priceTable: TABLE, model: 'model-nobody-priced' }), /no entry for model/);
85
+ });
86
+ check('P4 the shipped default policy is valid and constructs', () => {
87
+ const q = new TenantQuotas({ policy: DEFAULT_QUOTA_POLICY, priceTable: TABLE, model: MODEL, clock: () => 0 });
88
+ q.admitTurn(tenantOf('acme', 'default-policy')).release();
89
+ });
90
+ console.log('P no raw tenant');
91
+ check('P5 every quota call takes a tenant derived at the seam; a string, a look-alike object or nothing is refused', () => {
92
+ const q = quotas();
93
+ const real = tenantOf('acme', 'u1');
94
+ const lookalike = { organization: real.organization, ledgerTenant: real.ledgerTenant };
95
+ for (const forged of [real.ledgerTenant, lookalike, JSON.parse(JSON.stringify(real)), null, undefined, 7]) {
96
+ assert.throws(() => q.admitTurn(forged), SeamTenantRequiredError);
97
+ assert.throws(() => q.recordLlmUsage(forged, MODEL, { inputTokens: 1, outputTokens: 1 }), SeamTenantRequiredError);
98
+ assert.throws(() => q.chargeUnprovenInvocation(forged), SeamTenantRequiredError);
99
+ assert.throws(() => q.admitBridgeJob(forged), SeamTenantRequiredError);
100
+ assert.throws(() => q.observeLedgerBytes(forged, 1), SeamTenantRequiredError);
101
+ assert.throws(() => q.forgetTenant(forged), SeamTenantRequiredError);
102
+ }
103
+ assert.equal(q.snapshot().tenants.length, 0, 'a refused forgery creates no account');
104
+ });
105
+ console.log('C concurrency');
106
+ check('C1 a tenant at its concurrent-turn cap is refused with its own scope, and a release frees exactly one slot', () => {
107
+ const q = quotas();
108
+ const t = tenantOf('acme', 'u1');
109
+ const first = q.admitTurn(t);
110
+ q.admitTurn(t);
111
+ const error = refusal(() => q.admitTurn(t));
112
+ assert.deepEqual([error.scope, error.dimension, error.limit], ['tenant', 'concurrent_turns', 2]);
113
+ first.release();
114
+ q.admitTurn(t);
115
+ assert.equal(refusal(() => q.admitTurn(t)).dimension, 'concurrent_turns');
116
+ });
117
+ check('C2 releasing a permit twice frees one slot, not two', () => {
118
+ const q = quotas();
119
+ const t = tenantOf('acme', 'u1');
120
+ const a = q.admitTurn(t);
121
+ q.admitTurn(t);
122
+ a.release();
123
+ a.release();
124
+ q.admitTurn(t);
125
+ const error = refusal(() => q.admitTurn(t));
126
+ assert.deepEqual([error.scope, error.dimension], ['tenant', 'concurrent_turns'], 'the double release did not open a third slot');
127
+ });
128
+ check('C3 the organization cap refuses a tenant that is within its own, naming the organization', () => {
129
+ const q = quotas();
130
+ const [u1, u2, u3] = [tenantOf('acme', 'u1'), tenantOf('acme', 'u2'), tenantOf('acme', 'u3')];
131
+ q.admitTurn(u1);
132
+ q.admitTurn(u1);
133
+ q.admitTurn(u2);
134
+ const error = refusal(() => q.admitTurn(u3));
135
+ assert.deepEqual([error.scope, error.dimension, error.limit], ['organization', 'concurrent_turns', 3]);
136
+ q.admitTurn(tenantOf('globex', 'u1'));
137
+ });
138
+ check('C4 the global cap refuses when no organization is over its own', () => {
139
+ const q = quotas();
140
+ q.admitTurn(tenantOf('acme', 'u1'));
141
+ q.admitTurn(tenantOf('acme', 'u2'));
142
+ q.admitTurn(tenantOf('globex', 'u1'));
143
+ q.admitTurn(tenantOf('globex', 'u2'));
144
+ const error = refusal(() => q.admitTurn(tenantOf('globex', 'u3')));
145
+ assert.deepEqual([error.scope, error.dimension, error.limit], ['global', 'concurrent_turns', 4]);
146
+ });
147
+ console.log('R rates');
148
+ check('R1 the turn rate is a rolling window with an exact retry hint, and the oldest entry leaves on the boundary', () => {
149
+ const q = quotas();
150
+ const t = tenantOf('acme', 'u1');
151
+ for (const at of [0, 100, 200]) {
152
+ now = at;
153
+ q.admitTurn(t).release();
154
+ }
155
+ now = 300;
156
+ const error = refusal(() => q.admitTurn(t));
157
+ assert.deepEqual([error.scope, error.dimension, error.limit, error.retryAfterMs, error.retryAfterSeconds], ['tenant', 'turn_rate', 3, 700, 1]);
158
+ now = 999;
159
+ assert.equal(refusal(() => q.admitTurn(t)).retryAfterMs, 1, 'one millisecond before the boundary');
160
+ now = 1_000;
161
+ q.admitTurn(t).release();
162
+ });
163
+ check('R2 the organization rate refuses tenants that are each within their own', () => {
164
+ const q = quotas();
165
+ let tick = 0;
166
+ for (const [subject, turns] of [['u1', 2], ['u2', 2], ['u3', 1]]) {
167
+ for (let i = 0; i < turns; i += 1) {
168
+ now = tick++;
169
+ q.admitTurn(tenantOf('acme', subject)).release();
170
+ }
171
+ }
172
+ now = 10;
173
+ const error = refusal(() => q.admitTurn(tenantOf('acme', 'u4')));
174
+ assert.deepEqual([error.scope, error.dimension, error.limit], ['organization', 'turn_rate', 5]);
175
+ });
176
+ check('R3 the bridge job rate counts per tenant and per organization, and an organization-only caller counts at the organization', () => {
177
+ const q = quotas();
178
+ const t = tenantOf('acme', 'u1');
179
+ q.admitBridgeJob(t);
180
+ q.admitBridgeJob(t);
181
+ const own = refusal(() => q.admitBridgeJob(t));
182
+ assert.deepEqual([own.scope, own.dimension, own.limit], ['tenant', 'bridge_jobs', 2]);
183
+ q.admitBridgeJob(tenantOf('acme', 'u2'));
184
+ const org = refusal(() => q.admitBridgeJob(tenantOf('acme', 'u3')));
185
+ assert.deepEqual([org.scope, org.dimension, org.limit], ['organization', 'bridge_jobs', 3]);
186
+ q.admitOrganizationBridgeJob('globex');
187
+ q.admitOrganizationBridgeJob('globex');
188
+ q.admitOrganizationBridgeJob('globex');
189
+ assert.equal(refusal(() => q.admitOrganizationBridgeJob('globex')).scope, 'organization');
190
+ });
191
+ check('R4 an organization id that is not path-safe is refused before an account exists', () => {
192
+ const q = quotas();
193
+ for (const bad of ['../x', 'ACME', '', 'a/b', 'a'.repeat(64), 'acme\n']) {
194
+ assert.throws(() => q.admitOrganizationBridgeJob(bad), InvalidTenantIdError, JSON.stringify(bad));
195
+ }
196
+ assert.equal(q.snapshot().organizations.length, 0);
197
+ });
198
+ console.log('L LLM budget');
199
+ check('L1 measured usage is priced by the frozen table: fresh input, cache-read input and output, and aliases resolve', () => {
200
+ const q = quotas();
201
+ const t = tenantOf('acme', 'u1');
202
+ q.recordLlmUsage(t, MODEL, { inputTokens: 100_000, cacheReadTokens: 200_000, outputTokens: 50_000 });
203
+ const expected = (100_000 * 0.44 + 200_000 * 0.014 + 50_000 * 1.32) / 1_000_000;
204
+ const entry = snapshotOf(q, t);
205
+ assert.equal(entry.llmTokens, 350_000);
206
+ assert.equal(entry.llmCostUsd, Number(expected.toFixed(6)));
207
+ q.recordLlmUsage(t, 'deepseek-chat', { inputTokens: 100_000, outputTokens: 0 });
208
+ assert.equal(snapshotOf(q, t).llmCostUsd, Number((expected + 0.044).toFixed(6)), 'the generation alias prices as its target');
209
+ assert.equal(q.snapshot().organizations[0].llmTokens, 450_000, 'the organization is charged too');
210
+ });
211
+ check('L2 an unpriced model, a malformed count or a report of zero tokens is refused and charges nothing', () => {
212
+ const q = quotas();
213
+ const t = tenantOf('acme', 'u1');
214
+ assert.throws(() => q.recordLlmUsage(t, 'model-nobody-priced', { inputTokens: 1, outputTokens: 1 }), /no entry for model/);
215
+ // no completion costs nothing: a relay that answers zeros is not measuring, and the caller must then charge the worst case
216
+ assert.throws(() => q.recordLlmUsage(t, MODEL, { inputTokens: 0, outputTokens: 0 }), /zero tokens/);
217
+ assert.throws(() => q.recordLlmUsage(t, MODEL, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }), /zero tokens/);
218
+ for (const bad of [-1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 2]) {
219
+ assert.throws(() => q.recordLlmUsage(t, MODEL, { inputTokens: bad, outputTokens: 1 }), RangeError, String(bad));
220
+ assert.throws(() => q.recordLlmUsage(t, MODEL, { inputTokens: 1, outputTokens: bad }), RangeError, String(bad));
221
+ assert.throws(() => q.recordLlmUsage(t, MODEL, { inputTokens: 1, outputTokens: 1, cacheReadTokens: bad }), RangeError, String(bad));
222
+ }
223
+ const entry = snapshotOf(q, t);
224
+ assert.ok(!entry || (entry.llmTokens === 0 && entry.llmCostUsd === 0));
225
+ });
226
+ check('L3 the token budget refuses the next turn once spent, names the dimension, and reopens when the charge ages out', () => {
227
+ const q = quotas();
228
+ const t = tenantOf('acme', 'u1');
229
+ q.recordLlmUsage(t, MODEL, { inputTokens: 600_000, outputTokens: 400_000 });
230
+ now = 4_000;
231
+ const error = refusal(() => q.admitTurn(t));
232
+ assert.deepEqual([error.scope, error.dimension, error.limit, error.retryAfterMs], ['tenant', 'llm_tokens', 1_000_000, 6_000]);
233
+ now = 10_000;
234
+ q.admitTurn(t).release();
235
+ });
236
+ check('L4 the cost budget refuses on cost alone, in dollars, before the token budget is touched', () => {
237
+ const q = quotas();
238
+ const t = tenantOf('acme', 'u1');
239
+ q.recordLlmUsage(t, MODEL, { inputTokens: 0, outputTokens: 400_000 });
240
+ assert.equal(snapshotOf(q, t).llmCostUsd, 0.528);
241
+ const error = refusal(() => q.admitTurn(t));
242
+ assert.deepEqual([error.scope, error.dimension, error.limit], ['tenant', 'llm_cost', 0.5]);
243
+ });
244
+ check('L5 the organization budget is the sum of its tenants and refuses a tenant that spent nothing', () => {
245
+ const q = quotas();
246
+ for (const subject of ['u1', 'u2', 'u3'])
247
+ q.recordLlmUsage(tenantOf('acme', subject), MODEL, { inputTokens: 700_000, outputTokens: 0 });
248
+ const error = refusal(() => q.admitTurn(tenantOf('acme', 'fresh')));
249
+ assert.deepEqual([error.scope, error.dimension], ['organization', 'llm_tokens']);
250
+ q.admitTurn(tenantOf('globex', 'fresh')).release();
251
+ });
252
+ check('L6 an invocation that reported nothing is charged the configured worst case, never zero', () => {
253
+ const q = quotas();
254
+ const t = tenantOf('acme', 'u1');
255
+ q.chargeUnprovenInvocation(t);
256
+ const entry = snapshotOf(q, t);
257
+ assert.deepEqual([entry.llmTokens, entry.llmCostUsd], [10_000, 0.05]);
258
+ for (let i = 0; i < 9; i += 1)
259
+ q.chargeUnprovenInvocation(t);
260
+ assert.equal(refusal(() => q.admitTurn(t)).dimension, 'llm_cost', 'ten silent invocations exhaust the cost budget');
261
+ });
262
+ console.log('S ledger size');
263
+ check('S1 a tenant ledger at its limit is refused with a long hint, one byte under is not', () => {
264
+ const q = quotas();
265
+ const t = tenantOf('acme', 'u1');
266
+ q.observeLedgerBytes(t, 999);
267
+ const error = refusal(() => q.observeLedgerBytes(t, 1_000));
268
+ assert.deepEqual([error.scope, error.dimension, error.limit], ['tenant', 'ledger_size', 1_000]);
269
+ assert.ok(error.retryAfterSeconds >= 3_600);
270
+ });
271
+ check('S2 the organization limit is the sum of its tenants served here, and a forget takes a tenant out of it', () => {
272
+ const q = quotas();
273
+ const [u1, u2] = [tenantOf('acme', 'u1'), tenantOf('acme', 'u2')];
274
+ q.observeLedgerBytes(u1, 800);
275
+ const error = refusal(() => q.observeLedgerBytes(u2, 800));
276
+ assert.deepEqual([error.scope, error.dimension, error.limit], ['organization', 'ledger_size', 1_500]);
277
+ q.forgetTenant(u1);
278
+ q.observeLedgerBytes(u2, 800);
279
+ q.observeLedgerBytes(u2, 900);
280
+ assert.throws(() => q.observeLedgerBytes(u2, -1), RangeError);
281
+ });
282
+ console.log('I isolation');
283
+ check('I1 a hot tenant is refused alone: its flood changes nothing for a neighbour, another organization, or the counters of either', () => {
284
+ const q = quotas();
285
+ const hot = tenantOf('acme', 'hot');
286
+ let admitted = 0;
287
+ let refused = 0;
288
+ for (let i = 0; i < 1_000; i += 1) {
289
+ try {
290
+ q.admitTurn(hot);
291
+ admitted += 1;
292
+ }
293
+ catch (error) {
294
+ assert.ok(error instanceof QuotaExceededError);
295
+ assert.equal(error.scope, 'tenant');
296
+ refused += 1;
297
+ }
298
+ }
299
+ assert.deepEqual([admitted, refused], [2, 998]);
300
+ const neighbour = tenantOf('acme', 'neighbour');
301
+ const other = tenantOf('globex', 'neighbour');
302
+ q.admitTurn(neighbour).release();
303
+ q.admitTurn(other).release();
304
+ assert.equal(snapshotOf(q, neighbour).rejected.concurrent_turns, 0);
305
+ assert.equal(snapshotOf(q, other).rejected.concurrent_turns, 0);
306
+ assert.equal(snapshotOf(q, hot).rejected.concurrent_turns, 998);
307
+ });
308
+ check('I2 a refusal reserves nothing: no slot, no rate entry, no account for a fresh tenant, no counter moves except the organization\'s refusal count', () => {
309
+ const q = quotas();
310
+ const [u1, u2, u3] = [tenantOf('acme', 'u1'), tenantOf('acme', 'u2'), tenantOf('acme', 'u3')];
311
+ q.admitTurn(u1);
312
+ q.admitTurn(u1);
313
+ q.admitTurn(u2);
314
+ const before = JSON.stringify(q.snapshot());
315
+ refusal(() => q.admitTurn(u3));
316
+ const after = q.snapshot();
317
+ assert.equal(after.globalActiveTurns, 3);
318
+ assert.equal(snapshotOf(q, u3), undefined, 'a refused fresh tenant is not remembered at all');
319
+ const organization = after.organizations.find((entry) => entry.key === 'acme');
320
+ assert.equal(organization.activeTurns, 3);
321
+ assert.equal(organization.turnsInWindow, 3);
322
+ assert.equal(organization.rejected.concurrent_turns, 1);
323
+ assert.notEqual(before, JSON.stringify(after), 'only the refusal count moved');
324
+ });
325
+ check('I3 counters are keyed by the organization and the HMAC tenant id and hold no subject, issuer or scope', () => {
326
+ const q = quotas();
327
+ const t = tenantOf('acme', 'alice@example.com');
328
+ q.admitTurn(t).release();
329
+ q.recordLlmUsage(t, MODEL, { inputTokens: 1, outputTokens: 1 });
330
+ const text = JSON.stringify(q.snapshot());
331
+ assert.match(q.snapshot().tenants[0].key, /^acme\/t-[0-9a-f]{32}$/);
332
+ for (const secret of ['alice', 'example.com', 'idp.example', 'booking:read'])
333
+ assert.ok(!text.includes(secret), `snapshot leaks ${secret}`);
334
+ });
335
+ check('I4 idle accounts are swept and take their ledger size out of the organization total; a live turn keeps its account', () => {
336
+ const q = quotas();
337
+ const keeper = tenantOf('acme', 'keeper');
338
+ q.observeLedgerBytes(keeper, 10);
339
+ assert.equal(q.snapshot().organizations.find((entry) => entry.key === 'acme')?.ledgerBytes, 10);
340
+ const live = tenantOf('acme', 'live');
341
+ const held = q.admitTurn(live);
342
+ for (let i = 0; i < 1_100; i += 1) {
343
+ now = i * 5_000;
344
+ q.admitTurn(tenantOf(i % 2 === 0 ? 'acme' : 'globex', `churn-${i}`)).release();
345
+ }
346
+ const keys = new Set(q.snapshot().tenants.map((entry) => entry.key));
347
+ assert.ok(keys.size < 200, `idle tenants were swept at the thousandth admission (${keys.size} left of 1102)`);
348
+ assert.ok(!keys.has(`acme/${keeper.ledgerTenant}`), 'an idle tenant is swept although its ledger has a size');
349
+ assert.ok(keys.has(`acme/${live.ledgerTenant}`), 'a live turn keeps its account');
350
+ assert.equal(q.snapshot().organizations.find((entry) => entry.key === 'acme')?.ledgerBytes, 0, 'the swept ledger left the organization total');
351
+ held.release();
352
+ });
353
+ check('I6 a fresh tenant that is refused leaves no account behind, and refused attempts advance the sweep', () => {
354
+ // rates are out of the way: only the organization's concurrency cap (3) refuses here
355
+ const q = quotas({ ...POLICY, tenant: { ...POLICY.tenant, turns: { limit: 1_000_000, windowMs: 1_000 } }, organization: { ...POLICY.organization, turns: { limit: 1_000_000, windowMs: 1_000 } } });
356
+ const idleTenants = Array.from({ length: 10 }, (_, n) => tenantOf('acme', `early-${n}`));
357
+ for (const tenant of idleTenants)
358
+ q.admitTurn(tenant).release();
359
+ const holders = [tenantOf('acme', 'h1'), tenantOf('acme', 'h2'), tenantOf('acme', 'h3')].map((tenant) => q.admitTurn(tenant));
360
+ // the organization cap (3) is full: every further tenant is refused, and none of them may be remembered
361
+ now = 1_000_000;
362
+ for (let i = 0; i < 1_100; i += 1) {
363
+ refusal(() => q.admitTurn(tenantOf('acme', `flood-${i}`)));
364
+ assert.ok(q.snapshot().tenants.length <= 13, 'a refused tenant was registered');
365
+ }
366
+ const keys = q.snapshot().tenants.map((entry) => entry.key);
367
+ assert.ok(!idleTenants.some((tenant) => keys.includes(`acme/${tenant.ledgerTenant}`)), 'the refused attempts advanced the sweep: the idle tenants are gone');
368
+ assert.equal(keys.length, 3, 'only the three live turns hold an account');
369
+ for (const holder of holders)
370
+ holder.release();
371
+ });
372
+ check('I5 a refusal is a typed tenant-state error with a code, a scope and a hint, and its message names no person', () => {
373
+ const q = quotas();
374
+ const t = tenantOf('acme', 'alice@example.com');
375
+ q.admitTurn(t);
376
+ q.admitTurn(t);
377
+ const error = refusal(() => q.admitTurn(t));
378
+ assert.ok(error instanceof TenantStateError);
379
+ assert.equal(error.code, 'GOTRY_QUOTA_EXCEEDED');
380
+ assert.ok(Number.isInteger(error.retryAfterMs) && error.retryAfterMs >= 1);
381
+ assert.ok(!error.message.includes('alice') && !error.message.includes(t.ledgerTenant));
382
+ });
383
+ console.log(`tenant-seam quota tests: ${passed} checks passed`);
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Shared fixtures of the AaaS A2 seam suites: a fleet of replicas that serve the Booking Copilot surface in its
3
+ * multi-tenant form over real HTTP, with a credential table standing in for the A3 authenticator, a scripted planner
4
+ * standing in for the model, and the helpers to read what landed in each tenant's own ledger.
5
+ *
6
+ * Everything is offline and deterministic: manual clocks, a directory for the object store, SQLite lease files, one
7
+ * state root per replica under a caller-owned temp directory. Nothing here reads the founder's runtime state.
8
+ */
9
+ import assert from 'node:assert/strict';
10
+ import { request as httpRequest } from 'node:http';
11
+ import { join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { BOOKING_SURFACE_SCHEMA_SHA256, BOOKING_SURFACE_SCHEMA_VERSION } from '../src/booking-surface/contracts.js';
14
+ import { startBookingCopilotServer } from '../src/booking-surface/server.js';
15
+ import { BookingTenancy, sharedPlannerProvider } from '../src/booking-surface/tenancy.js';
16
+ import { loadPriceTable } from '../src/llm-price-table.js';
17
+ import { deriveSeamTenant, StaticOrganizationKeyring, TenantQuotas, } from '../src/tenant-seam/index.js';
18
+ import { DirDurableStore } from '../src/tenant-state/durable-store.js';
19
+ import { SqliteLeaseStore } from '../src/tenant-state/lease-store.js';
20
+ import { TenantRouter } from '../src/tenant-state/tenant-router.js';
21
+ import { ManualClock } from './tenant-state-test-kit.js';
22
+ export const TS_ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..');
23
+ export const PRICE_TABLE = loadPriceTable(join(TS_ROOT, 'data', 'llm-price-table.json'));
24
+ export const MODEL = 'deepseek-v4-flash';
25
+ export const TTL = 10_000;
26
+ export const MARGIN = 2_000;
27
+ export const KEYRING = new StaticOrganizationKeyring({ acme: Buffer.alloc(32, 0x31), globex: Buffer.alloc(32, 0x32) });
28
+ /** Credential -> caller. This table is the test double of the A3 authenticator; the seam trusts nothing else. */
29
+ export const CALLERS = {
30
+ 'cred-acme-alice': { organization: 'acme', issuer: 'https://idp.acme.example', subject: 'alice', scope: 'booking:read' },
31
+ 'cred-acme-bob': { organization: 'acme', issuer: 'https://idp.acme.example', subject: 'bob', scope: 'booking:read' },
32
+ 'cred-acme-carol': { organization: 'acme', issuer: 'https://idp.acme.example', subject: 'carol', scope: 'booking:read' },
33
+ 'cred-acme-alice-elsewhere': { organization: 'acme', issuer: 'https://other-idp.acme.example', subject: 'alice', scope: 'booking:read' },
34
+ 'cred-globex-alice': { organization: 'globex', issuer: 'https://idp.globex.example', subject: 'alice', scope: 'booking:read' },
35
+ };
36
+ export const ROOMY = {
37
+ tenant: {
38
+ concurrentTurns: 4,
39
+ turns: { limit: 1_000, windowMs: 60_000 },
40
+ llm: { tokens: 1_000_000_000, costUsd: 1_000, windowMs: 3_600_000 },
41
+ bridgeJobs: { limit: 100, windowMs: 60_000 },
42
+ ledgerBytes: 1_000_000_000,
43
+ },
44
+ organization: {
45
+ concurrentTurns: 16,
46
+ turns: { limit: 10_000, windowMs: 60_000 },
47
+ llm: { tokens: 1_000_000_000, costUsd: 1_000, windowMs: 3_600_000 },
48
+ bridgeJobs: { limit: 1_000, windowMs: 60_000 },
49
+ ledgerBytes: 1_000_000_000_000,
50
+ },
51
+ globalConcurrentTurns: 64,
52
+ unprovenInvocation: { tokens: 10_000, costUsd: 0.05 },
53
+ };
54
+ export function tenantOf(credential) {
55
+ const caller = CALLERS[credential];
56
+ assert.ok(caller, `unknown test credential ${credential}`);
57
+ return deriveSeamTenant(caller, KEYRING);
58
+ }
59
+ // ---- requests ---------------------------------------------------------------------------
60
+ export const workspace = (contextRef, currency = 'AED') => ({
61
+ schemaVersion: 'booking.surface',
62
+ contextRef,
63
+ surface: 'tenant',
64
+ revision: 0,
65
+ locale: 'en-US',
66
+ currency,
67
+ searchDraft: {},
68
+ results: { status: 'idle' },
69
+ visibleHotels: [],
70
+ loadedOffers: [],
71
+ shortlistedOfferRefs: [],
72
+ capabilities: { surface: 'tenant', allowedActions: ['search.run'] },
73
+ });
74
+ export const userTurn = (taskId, contextRef, text, currency = 'AED') => ({
75
+ schemaVersion: 'booking.surface',
76
+ kind: 'user.turn',
77
+ taskId,
78
+ turnId: `${taskId}-turn-1`,
79
+ workspace: workspace(contextRef, currency),
80
+ request: { text },
81
+ });
82
+ export const ingressTurn = (requestKey, taskHandle, text) => ({
83
+ schemaVersion: 'booking.surface',
84
+ kind: 'user.turn.ingress',
85
+ requestKey,
86
+ taskHandle,
87
+ surfaceHint: 'tenant',
88
+ workspace: { schemaVersion: 'booking.surface', revision: 0, locale: 'en-US', currency: 'AED', searchDraft: {}, results: { status: 'idle' }, visibleHotels: [], loadedOffers: [], shortlistedOfferRefs: [] },
89
+ request: { text },
90
+ });
91
+ export function sseEvents(text) {
92
+ return text.split('\n\n').filter((block) => block.trim() !== '').map((block) => {
93
+ const lines = block.split('\n');
94
+ const event = lines.find((line) => line.startsWith('event: '))?.slice(7) ?? '';
95
+ const data = lines.find((line) => line.startsWith('data: '))?.slice(6) ?? 'null';
96
+ return { event, data: JSON.parse(data) };
97
+ });
98
+ }
99
+ /** Every key name anywhere in a JSON value. */
100
+ export function keyNamesOf(value, into = new Set()) {
101
+ if (Array.isArray(value))
102
+ for (const item of value)
103
+ keyNamesOf(item, into);
104
+ else if (value && typeof value === 'object') {
105
+ for (const [key, child] of Object.entries(value)) {
106
+ into.add(key);
107
+ keyNamesOf(child, into);
108
+ }
109
+ }
110
+ return into;
111
+ }
112
+ export function gate() {
113
+ let open;
114
+ let entered;
115
+ const wait = new Promise((resolve) => { open = resolve; });
116
+ const enteredPromise = new Promise((resolve) => { entered = resolve; });
117
+ return { gate: { entered: enteredPromise, open }, wait, markEntered: entered };
118
+ }
119
+ function textOf(turn) {
120
+ return turn.kind === 'user.turn' || turn.kind === 'user.turn.ingress' ? turn.request.text : '';
121
+ }
122
+ export function searchRunDecision(taskId, contextRef, revision, who) {
123
+ return {
124
+ kind: 'operation',
125
+ action: {
126
+ schemaVersion: 'booking.surface',
127
+ kind: 'search.run',
128
+ actionId: `action-${taskId}`,
129
+ contextRef,
130
+ expectedRevision: revision,
131
+ reason: `Run the authoritative workspace search for ${who}`,
132
+ factRefs: [],
133
+ input: {},
134
+ },
135
+ intent: { schemaVersion: 'booking.intent.v1', target: 'search.results' },
136
+ };
137
+ }
138
+ export function makePlanner() {
139
+ const holds = [];
140
+ const kit = {
141
+ calls: [],
142
+ factoryCalls: 0,
143
+ closes: 0,
144
+ script: null,
145
+ hold(pattern) {
146
+ const held = gate();
147
+ holds.push({ pattern, gate: held });
148
+ return held.gate;
149
+ },
150
+ releaseAll() {
151
+ for (const held of holds)
152
+ held.gate.gate.open();
153
+ },
154
+ factory: () => {
155
+ kit.factoryCalls += 1;
156
+ const session = {
157
+ async next({ turn, task }) {
158
+ const text = textOf(turn);
159
+ kit.calls.push({ taskId: task.taskId, contextRef: task.contextRef, text });
160
+ for (const held of holds) {
161
+ if (held.pattern.test(text)) {
162
+ held.gate.markEntered();
163
+ await held.gate.wait;
164
+ }
165
+ }
166
+ if (kit.script)
167
+ return kit.script({ turn, taskId: task.taskId, contextRef: task.contextRef, revision: task.revision, text });
168
+ // A receipt closes the task; anything else runs the search for whoever the text says it is for.
169
+ if (turn.kind === 'action.receipt.continuation') {
170
+ return [
171
+ { kind: 'explanation', explanation: { text: 'The search ran.', factRefs: [] } },
172
+ { kind: 'terminal', terminal: { status: 'completed', summary: 'Stopped at the requested search result.', factRefs: [] } },
173
+ ];
174
+ }
175
+ return [searchRunDecision(task.taskId, task.contextRef, task.revision, text.split(' as ')[1] ?? 'anyone')];
176
+ },
177
+ async close() { kit.closes += 1; },
178
+ };
179
+ return session;
180
+ },
181
+ };
182
+ return kit;
183
+ }
184
+ export function makeFleet(dir, cleanups) {
185
+ const storeClock = new ManualClock(1_000_000);
186
+ const leases = new SqliteLeaseStore({ path: join(dir, 'leases.db'), nowMs: () => storeClock.nowMs() });
187
+ cleanups.push(() => leases.close());
188
+ const clocks = [];
189
+ return {
190
+ dir,
191
+ storeClock,
192
+ leases,
193
+ durable: new DirDurableStore(join(dir, 'durable')),
194
+ clocks,
195
+ advance(ms, frozen = []) {
196
+ storeClock.advance(ms);
197
+ for (const clock of clocks)
198
+ if (!frozen.includes(clock))
199
+ clock.advance(ms);
200
+ },
201
+ };
202
+ }
203
+ export async function makeService(fleet, id, cleanups, options = {}) {
204
+ const clock = new ManualClock(5_000);
205
+ fleet.clocks.push(clock);
206
+ const stateRoot = join(fleet.dir, 'nodes', id);
207
+ const router = new TenantRouter({
208
+ stateRoot, replicaId: id, leases: fleet.leases, durable: fleet.durable, ttlMs: TTL, marginMs: MARGIN, clock, autoRenew: false,
209
+ });
210
+ const quotas = new TenantQuotas({ policy: options.policy ?? ROOMY, priceTable: PRICE_TABLE, model: MODEL, clock: () => clock.nowMs() });
211
+ const planner = options.planner ?? makePlanner();
212
+ const seenHeaders = [];
213
+ const bound = [];
214
+ const authenticator = {
215
+ authenticate(request) {
216
+ seenHeaders.push(Object.keys(request.headers).sort());
217
+ const match = /^Bearer (cred-[a-z0-9-]+)$/.exec(String(request.headers.authorization ?? ''));
218
+ return match ? (CALLERS[match[1]] ?? null) : null;
219
+ },
220
+ };
221
+ const binding = {
222
+ bind(input, principal) {
223
+ bound.push({ requestKey: input.requestKey, principal: { ...principal } });
224
+ return { taskId: 'task-bound', turnId: `turn-${input.requestKey}`, contextRef: 'ctx-bound', surface: 'tenant', allowedActions: ['search.run'] };
225
+ },
226
+ };
227
+ const tenancy = new BookingTenancy({
228
+ authenticator,
229
+ keyring: KEYRING,
230
+ router,
231
+ quotas,
232
+ planners: options.provider ?? sharedPlannerProvider(planner.factory),
233
+ ...(options.ingress ? { ingressBinding: binding } : {}),
234
+ });
235
+ const server = await startBookingCopilotServer({ tenancy: options.wrapTenancy ? options.wrapTenancy(tenancy) : tenancy, host: '127.0.0.1', port: 0 });
236
+ const endpoint = `http://127.0.0.1:${server.port}/a2a/booking-copilot/turn`;
237
+ const schemaHeaders = { 'x-booking-surface-version': BOOKING_SURFACE_SCHEMA_VERSION, 'x-booking-surface-schema-sha256': BOOKING_SURFACE_SCHEMA_SHA256 };
238
+ let closed = false;
239
+ const service = {
240
+ id, stateRoot, router, quotas, tenancy, planner, clock, server, endpoint, seenHeaders, bound,
241
+ async post(body, credential, extraHeaders = {}) {
242
+ const response = await fetch(endpoint, {
243
+ method: 'POST',
244
+ headers: { 'content-type': 'application/json', ...schemaHeaders, ...(credential ? { authorization: `Bearer ${credential}` } : {}), ...extraHeaders },
245
+ body: typeof body === 'string' ? body : JSON.stringify(body),
246
+ });
247
+ const headers = {};
248
+ response.headers.forEach((value, key) => { headers[key] = value; });
249
+ return { status: response.status, headers, text: await response.text() };
250
+ },
251
+ postRaw(body, credential, onData) {
252
+ return new Promise((resolve, reject) => {
253
+ const payload = JSON.stringify(body);
254
+ const req = httpRequest(endpoint, {
255
+ method: 'POST',
256
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload), authorization: `Bearer ${credential}`, ...schemaHeaders },
257
+ }, (res) => {
258
+ let text = '';
259
+ res.setEncoding('utf8');
260
+ res.on('data', (chunk) => { text += chunk; onData?.(chunk); });
261
+ res.on('end', () => resolve({ status: res.statusCode ?? 0, text, ended: 'end' }));
262
+ res.on('aborted', () => resolve({ status: res.statusCode ?? 0, text, ended: 'aborted' }));
263
+ res.on('error', () => resolve({ status: res.statusCode ?? 0, text, ended: 'aborted' }));
264
+ res.on('close', () => resolve({ status: res.statusCode ?? 0, text, ended: res.complete ? 'end' : 'aborted' }));
265
+ });
266
+ req.on('error', reject);
267
+ req.end(payload);
268
+ });
269
+ },
270
+ handleOf(credential) {
271
+ const tenant = tenantOf(credential);
272
+ return router.handleOf(tenant.organization, tenant.ledgerTenant);
273
+ },
274
+ async close() {
275
+ if (closed)
276
+ return;
277
+ closed = true;
278
+ planner.releaseAll();
279
+ await server.close().catch(() => undefined);
280
+ await tenancy.close().catch(() => undefined);
281
+ },
282
+ };
283
+ cleanups.push(() => service.close());
284
+ return service;
285
+ }
286
+ /** All rows of a tenant's ledger, or `null` when this replica has not opened the tenant (so nothing was written here). */
287
+ export function rowsOf(service, credential) {
288
+ const handle = service.handleOf(credential);
289
+ if (!handle)
290
+ return null;
291
+ return handle.run((ledger) => ledger.db.prepare('SELECT seq, kind, run_id, idem_key, tenant_id, payload FROM events ORDER BY seq').all());
292
+ }