browser-broker 0.1.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.
Files changed (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,692 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { BROWSER_CHOICE_GUIDANCE } from "../../browser/driver.js";
3
+ import { admits, countActiveClaims } from "../capacity.js";
4
+ import { append } from "../events.js";
5
+ import { hashKey, mintKey } from "../keys.js";
6
+ import { nudgeIfOwnObstacle } from "../nudge.js";
7
+ import { queuePosition, waitEstimateSeconds } from "../queue.js";
8
+ import { CallRefusal } from "../refusals.js";
9
+ import { StorageSeedRefusal, seedRecord, validateStorageSeed, } from "../storage-seed.js";
10
+ /**
11
+ * `browser_claim` (§3.2) — atomic grant-or-queue for exactly one tab.
12
+ *
13
+ * **One claim, one tab, one row** (`MILESTONES.md` #13). There is no `tabs`
14
+ * argument and its absence is the model (§2.3): a caller that wants three
15
+ * calls this three times, and should read §2.3a first, because doing so while
16
+ * other callers do the same is a named limit rather than a solved problem.
17
+ *
18
+ * ── Why grant and queue are one operation rather than two ───────────────
19
+ *
20
+ * They are the two outcomes of one decision, taken against a count the same
21
+ * transaction has just reconciled (§7.1 `capacity.admission`). Splitting them
22
+ * would mean a caller asking "is there room" and then asking to be granted,
23
+ * with every other process free to take the room in between — which is the
24
+ * read-then-write window §1.0a measures as failing. **The count is read and
25
+ * the row is written inside one transaction, or the answer is a guess.**
26
+ *
27
+ * ── What is not decided here ────────────────────────────────────────────
28
+ *
29
+ * The sweep. It has already run by the time this handler is called, because
30
+ * the runner sweeps unconditionally before any handler (`arbitration.ts`), so
31
+ * every count below is over reconciled state. A handler cannot skip it, opt
32
+ * out of it, or run before it — which is the standing invariant made
33
+ * mechanical rather than remembered.
34
+ */
35
+ /**
36
+ * The purpose bound (§1.3), stated here because this is where it is enforced.
37
+ *
38
+ * **The same two numbers as `claims.purpose`'s `CHECK`**, and they have to
39
+ * stay the same two: the guard's job is to make sure no caller ever reaches
40
+ * that constraint, which it can only do if it refuses exactly what the column
41
+ * would have refused. A guard with a *wider* bound would let a caller back
42
+ * through to the crash it exists to prevent; a narrower one would refuse
43
+ * purposes the store would have accepted.
44
+ *
45
+ * They are not imported from the schema module because the schema is SQL text
46
+ * — there is nothing there to import — and duplicating the pair with the
47
+ * reason stated is honest, where deriving one from a parse of the other would
48
+ * be fragile in the direction that fails silently. A test pins them against
49
+ * the live column instead (`tests/operations/claim-purpose.test.ts`), so the
50
+ * two moving apart is a failing test rather than a returning crash.
51
+ */
52
+ export const PURPOSE_MINIMUM = 3;
53
+ /** @see PURPOSE_MINIMUM */
54
+ export const PURPOSE_MAXIMUM = 200;
55
+ /**
56
+ * The session-identity bound (§1.3), enforced here for the same reason the
57
+ * purpose bound above is — and with one difference that makes it worse.
58
+ *
59
+ * **`claims.session_id` is `TEXT NOT NULL` with no `CHECK` at all.** The
60
+ * purpose had a column constraint standing behind it, so the missing guard
61
+ * showed itself as a crash: ugly, but loud, and fixed within a day of being
62
+ * seen. An empty session identity satisfies `NOT NULL`, so there was nothing
63
+ * behind it to fail — **the lease was granted, and the defect was silent.**
64
+ *
65
+ * ── Why an anonymous lease is not a cosmetic problem ────────────────────
66
+ *
67
+ * §1.3 makes `session_id` the attribution key, and §1.6 puts it on **every
68
+ * refusal row** precisely so a denied request is not anonymous. An empty one
69
+ * defeats both: the ledger records a grant and a denial with nothing saying
70
+ * who. Worse, the you-are-your-own-obstacle nudge (§2.3a) selects live claims
71
+ * by `session_id = @sessionId`, so **every anonymous caller matches every
72
+ * other anonymous caller** — the nudge would list one caller's leases to a
73
+ * different caller and advise it to release them.
74
+ *
75
+ * ── The bound, and why there is no maximum ──────────────────────────────
76
+ *
77
+ * A minimum of one: the identifier is opaque to this service (§1.3 calls it
78
+ * "a key another system owns"), so its *content* is not this service's to
79
+ * judge. What is judgeable is whether one arrived at all, which is the whole
80
+ * defect. No maximum is imposed because the column imposes none, and a guard
81
+ * that refused what the store would have accepted would be inventing a rule
82
+ * rather than enforcing one — the mirror of the reasoning that keeps
83
+ * {@link PURPOSE_MAXIMUM} pinned to its column's `CHECK`.
84
+ */
85
+ export const SESSION_ID_MINIMUM = 1;
86
+ /**
87
+ * How the refusal describes a missing session identity.
88
+ *
89
+ * Two cases rather than one, for the reason {@link describePurpose} gives:
90
+ * "was not supplied" and "is blank" are the same mistake from the caller's
91
+ * side, but a value that is present and of the wrong *type* is a different
92
+ * one, and telling them apart is what stops a caller re-sending the same
93
+ * malformed argument.
94
+ */
95
+ function describeSessionId(sessionId) {
96
+ if (typeof sessionId !== 'string') {
97
+ return sessionId === undefined
98
+ ? 'was not supplied'
99
+ : `arrived as ${typeof sessionId} rather than as text`;
100
+ }
101
+ return 'is empty';
102
+ }
103
+ /**
104
+ * How the refusal describes what was wrong, in the caller's own terms.
105
+ *
106
+ * Three sentences rather than one, because "missing", "too short" and "too
107
+ * long" are three different mistakes with three different fixes, and a single
108
+ * "invalid purpose" would make the caller work out which it made.
109
+ */
110
+ function describePurpose(purpose) {
111
+ if (typeof purpose !== 'string' || purpose.length === 0) {
112
+ // Covers both the argument that was never supplied and the empty string a
113
+ // surface produces from a missing one — indistinguishable by the time
114
+ // they arrive here, and the same mistake from the caller's side.
115
+ return 'is missing';
116
+ }
117
+ if (purpose.length < PURPOSE_MINIMUM) {
118
+ return `is ${String(purpose.length)} character${purpose.length === 1 ? '' : 's'} long`;
119
+ }
120
+ return `is ${String(purpose.length)} characters long`;
121
+ }
122
+ /**
123
+ * How far under the lifetime a caller is told to check back.
124
+ *
125
+ * Nine parts in ten, which against the ten-minute default is the nine minutes
126
+ * §2.5 and `MILESTONES.md` #17 both name. **Expressed as a fraction rather
127
+ * than as a fixed sixty seconds** so it stays *under* the lifetime when the
128
+ * lifetime is configured shorter — a fixed subtraction would become zero or
129
+ * negative on a short queue place, and would tell a caller to check back in
130
+ * the past.
131
+ */
132
+ export const CHECK_BACK_FRACTION = 0.9;
133
+ /** The check-back deadline in seconds, always at least one. */
134
+ export function checkBackSeconds(lifetimeSeconds) {
135
+ return Math.max(1, Math.floor(lifetimeSeconds * CHECK_BACK_FRACTION));
136
+ }
137
+ /**
138
+ * What a caller asked for, turned into the browser it gets (§3.2).
139
+ *
140
+ * **Three forms, and a name is checked last rather than first.** The two kind
141
+ * words are the ones the default configuration also uses as names, so an
142
+ * installation that has renamed nothing resolves `regular` by kind and lands
143
+ * on the browser called `regular` either way — the orders agree. Where they
144
+ * would disagree is an installation whose signed-in list does not contain a
145
+ * browser named `regular`, and there the kind reading is the one that keeps
146
+ * working, which is why it wins.
147
+ *
148
+ * That ordering has a consequence worth stating rather than discovering: a
149
+ * browser **named** `regular` sitting in the *private* list would be
150
+ * unreachable by name. It cannot arise, because a name in both lists is
151
+ * refused at startup and `regular` in the private list still resolves as a
152
+ * kind — but the reason it cannot arise is the startup refusal, not this
153
+ * function.
154
+ *
155
+ * Returns `undefined` for a name no configured browser has, which the caller
156
+ * turns into the refusal; this function does not refuse, so that the ledger
157
+ * write and the refusal text stay in one place.
158
+ */
159
+ export function resolveBrowser(requested, settings) {
160
+ // Nothing stated: the first signed-in browser. **Not a guess between two
161
+ // symmetric wrongs** (`DECISIONS.md` §13i) — defaulting to clean-room when
162
+ // a sign-in was wanted returns a login redirect, a wrong page that looks
163
+ // like a right one; defaulting to signed-in when clean-room was wanted
164
+ // returns a personalised page, which is the page most callers asked for.
165
+ if (requested === undefined) {
166
+ return settings.regularBrowsers[0];
167
+ }
168
+ if (requested === 'regular') {
169
+ return settings.regularBrowsers[0];
170
+ }
171
+ if (requested === 'private') {
172
+ return settings.privateBrowsers[0];
173
+ }
174
+ if (settings.regularBrowsers.includes(requested) ||
175
+ settings.privateBrowsers.includes(requested)) {
176
+ return requested;
177
+ }
178
+ return undefined;
179
+ }
180
+ /** Every configured browser, both kinds, for a refusal that lists them. */
181
+ export function configuredBrowsers(settings) {
182
+ return [...settings.regularBrowsers, ...settings.privateBrowsers];
183
+ }
184
+ /**
185
+ * Decide one claim: grant it a tab, or put it at the back of the queue.
186
+ *
187
+ * The order of the two refusals below is deliberate. **An unknown browser is
188
+ * refused before anything is written**, because nothing will ever make it
189
+ * valid (§2.2) — waiting does not help and a queue entry would be a promise
190
+ * the service cannot keep. Both refusals leave the ledger with a row and the
191
+ * claims table without one, which is what makes a refused request anonymous
192
+ * without `events.session_id` (§1.6) and is why that column exists.
193
+ */
194
+ export function decideClaim(scope, input, settings) {
195
+ const { db, swept, adapter } = scope;
196
+ // **The session identity, checked here rather than by the column** (§7.1
197
+ // `claim.session_bounded`).
198
+ //
199
+ // ── Why this is first, ahead of even the browser check ────────────────
200
+ //
201
+ // Every refusal below records `sessionId: input.sessionId` on the ledger
202
+ // row, because §1.6 exists so that a denied request is attributable. If the
203
+ // identity is the thing that is missing, those rows are anonymous — so the
204
+ // ordering that puts this first is not a preference, it is what makes every
205
+ // *other* refusal on this operation carry a caller. The purpose guard sits
206
+ // after the browser guard because a caller that named a browser that does
207
+ // not exist should hear about the name first; nothing outranks knowing who
208
+ // is asking.
209
+ //
210
+ // ── What the store would have done, which is the point ────────────────
211
+ //
212
+ // Accepted it. `claims.session_id` is `TEXT NOT NULL` with no `CHECK`, and
213
+ // `''` satisfies `NOT NULL` — so unlike the purpose, there is no backstop
214
+ // here and never was. Before this guard, `broker claim` with no
215
+ // `--session-id` returned a granted lease with a real key and a real tab,
216
+ // and wrote `session_id = ''` to the claims row and to all three of its
217
+ // ledger events. Nothing failed, which is why it lasted.
218
+ //
219
+ // Refused **before the first insert and before the arrival counter is
220
+ // allocated**, so a refused caller is not charged capacity, holds no key,
221
+ // and has nothing to release — the position every other argument refusal
222
+ // on this operation takes, for that same reason.
223
+ if (typeof input.sessionId !== 'string' || input.sessionId.length < SESSION_ID_MINIMUM) {
224
+ scope.recordRefusal({
225
+ kind: 'claim_requested',
226
+ outcome: 'deny',
227
+ guard: 'claim.session_bounded',
228
+ adapter,
229
+ // Deliberately **not** `sessionId: input.sessionId`. Every other
230
+ // refusal here attaches the caller; this is the one call where doing so
231
+ // would write the empty string this guard exists to keep out, turning
232
+ // the record of the defect into an instance of it. The column is
233
+ // nullable on `events` (§1.6), so absent is expressible and honest.
234
+ detail: {
235
+ supplied: typeof input.sessionId === 'string' ? input.sessionId.length : null,
236
+ minimum: SESSION_ID_MINIMUM,
237
+ },
238
+ });
239
+ throw new CallRefusal('session_id_missing',
240
+ // Names the argument, says what is wrong with it, and says what it is
241
+ // *for* — a caller that thinks this is a formality supplies a constant
242
+ // and defeats the attribution just as thoroughly as omitting it.
243
+ `A claim carries the identity of the session asking for it, and this one ${describeSessionId(input.sessionId)}. Pass a stable identifier for your session: it is what attributes this lease and every refusal on it in the ledger, and it is how the service can tell you when you are waiting on capacity you are already holding.`, {
244
+ detail: {
245
+ supplied: typeof input.sessionId === 'string' ? input.sessionId.length : null,
246
+ minimum: SESSION_ID_MINIMUM,
247
+ },
248
+ });
249
+ }
250
+ // **Resolved rather than merely checked** (§3.2, `DECISIONS.md` §13i).
251
+ // Nothing stated resolves to the first signed-in browser, a kind word to
252
+ // the first of that kind, and a name to that browser exactly.
253
+ const resolvedBrowser = resolveBrowser(input.browser, settings);
254
+ if (resolvedBrowser === undefined) {
255
+ const known = configuredBrowsers(settings);
256
+ scope.recordRefusal({
257
+ kind: 'claim_requested',
258
+ outcome: 'deny',
259
+ guard: 'claim.browser_known',
260
+ adapter,
261
+ sessionId: input.sessionId,
262
+ detail: { requested: input.browser, known },
263
+ });
264
+ // **The refusal carries the choice guidance** (§3.2, row #66): a caller
265
+ // re-reading a refusal is a caller re-making this decision, and telling
266
+ // it only which names exist leaves it to guess which one it wanted.
267
+ throw new CallRefusal('unknown_browser', `There is no browser named ${JSON.stringify(input.browser)}. This service has ${known.join(', ')}. ${BROWSER_CHOICE_GUIDANCE}`, { detail: { requested: input.browser, known } });
268
+ }
269
+ // **The purpose, checked here rather than by the column** (§7.1
270
+ // `claim.purpose_bounded`).
271
+ //
272
+ // ── Why this guard exists, given the column already had a CHECK ────────
273
+ //
274
+ // Because a `CHECK` is not a refusal. Before this guard the bound was
275
+ // enforced only by `claims.purpose`'s own
276
+ // `CHECK (length(purpose) BETWEEN 3 AND 200)`, which fires *after* the
277
+ // insert is handed to the store — so the command line died with an
278
+ // unhandled `SqliteError` and exit 1, and the tool surface answered
279
+ // `unexpected_failure` with the constraint text in it. A caller was told
280
+ // the name of a database constraint rather than the name of the argument
281
+ // it got wrong, on the first command anybody runs.
282
+ //
283
+ // The column keeps its CHECK, and that is deliberate: it is the backstop
284
+ // for a writer that is not this function. What changes is that no caller
285
+ // reaches it any more, because the argument is refused before a statement
286
+ // is built.
287
+ //
288
+ // ── The position ──────────────────────────────────────────────────────
289
+ //
290
+ // **Before the first insert and before the arrival counter is allocated**,
291
+ // for the reason the seed check below gives: a refused caller is not
292
+ // charged capacity, holds no key, and has nothing to release. It sits
293
+ // *after* the browser check because a caller that named a browser that does
294
+ // not exist should hear about the name first — the same ordering, and the
295
+ // same reasoning, that puts the sign-in check after both.
296
+ //
297
+ // Length is counted in UTF-16 code units, which is what SQLite's `length()`
298
+ // counts over a `TEXT` value and therefore what the column would have
299
+ // measured. Counting anything else here would refuse strings the store
300
+ // would have taken, or take strings it would have refused.
301
+ if (typeof input.purpose !== 'string' ||
302
+ input.purpose.length < PURPOSE_MINIMUM ||
303
+ input.purpose.length > PURPOSE_MAXIMUM) {
304
+ scope.recordRefusal({
305
+ kind: 'claim_requested',
306
+ outcome: 'deny',
307
+ guard: 'claim.purpose_bounded',
308
+ adapter,
309
+ sessionId: input.sessionId,
310
+ // The length and the bounds, and **never the purpose itself** — the
311
+ // same discipline the seed refusal keeps, for the same reason: a
312
+ // refusal's detail is read by whoever reads the ledger.
313
+ detail: {
314
+ length: typeof input.purpose === 'string' ? input.purpose.length : null,
315
+ minimum: PURPOSE_MINIMUM,
316
+ maximum: PURPOSE_MAXIMUM,
317
+ },
318
+ });
319
+ throw new CallRefusal('purpose_out_of_bounds',
320
+ // Names the argument and what is wrong with it, and says what the
321
+ // purpose is *for* — an operator deciding whether to revoke this lease
322
+ // reads it, so "one line about the work" is the guidance that produces
323
+ // a useful one rather than three filler characters.
324
+ `A claim carries a purpose of ${String(PURPOSE_MINIMUM)} to ${String(PURPOSE_MAXIMUM)} characters, and this one ${describePurpose(input.purpose)}. Say what the lease is for in one line — it is what an operator reads when deciding whether to revoke it, so name the work rather than the tool.`, {
325
+ detail: {
326
+ length: typeof input.purpose === 'string' ? input.purpose.length : null,
327
+ minimum: PURPOSE_MINIMUM,
328
+ maximum: PURPOSE_MAXIMUM,
329
+ },
330
+ });
331
+ }
332
+ // Validated **before the first insert**, so a refused seed leaves no lease
333
+ // behind: the caller is not charged capacity for it, holds no key, and has
334
+ // nothing to release. This is the position the unknown-browser refusal
335
+ // occupies, and it is here for the same reason (§2.2) — nothing about
336
+ // waiting makes a malformed argument valid.
337
+ //
338
+ // The fifth refusal §3.2 lists — **any entry on a lease that is not the
339
+ // caller's** — needs no check here and could not be written as one: the
340
+ // seed is an argument on the claim, so it applies once, to the tab that
341
+ // claim grants. There is no parameter naming another lease, so there is no
342
+ // path to seeding somebody else's. That is structural, and it is stated
343
+ // rather than implemented because implementing it would mean inventing the
344
+ // parameter first.
345
+ //
346
+ // **The refusal is recorded through `recordRefusal`, not `append`**, for
347
+ // the reason that scope explains at length: a refusal throws, the throw
348
+ // rolls the transaction back, and an ordinary append goes with it — leaving
349
+ // a ledger of grants that can never show a guard firing. §1.6 requires
350
+ // every decision, allowed and refused alike.
351
+ let storageSeed;
352
+ try {
353
+ storageSeed = validateStorageSeed(input.storageSeed);
354
+ }
355
+ catch (error) {
356
+ if (error instanceof StorageSeedRefusal) {
357
+ scope.recordRefusal({
358
+ kind: 'claim_requested',
359
+ outcome: 'deny',
360
+ guard: error.rule,
361
+ adapter,
362
+ sessionId: input.sessionId,
363
+ // The refusal's own detail, which carries counts, byte sizes, the
364
+ // scheme and the area — **and never a value**, because nothing in
365
+ // this module ever puts one in a refusal's detail.
366
+ detail: { ...error.detail },
367
+ });
368
+ }
369
+ throw error;
370
+ }
371
+ const browserId = resolvedBrowser;
372
+ // **The row for a configured browser is created here, on the way to its
373
+ // first launch** (§1.2, `DECISIONS.md` §13i).
374
+ //
375
+ // ── Why a row has to exist before the insert below ────────────────────
376
+ //
377
+ // `claims.browser_id` is `REFERENCES browsers (id)`, and `tabs` carries a
378
+ // composite key back to `(claims.id, claims.browser_id)`. Those constraints
379
+ // are what keep a claim pointing at a browser this service actually
380
+ // manages, and they are worth keeping. Without a row, a claim on a
381
+ // configured browser fails as a foreign-key violation — a database error
382
+ // naming neither the browser nor the argument, which is precisely the
383
+ // shape of failure the `claim.browser_known` guard above exists to replace.
384
+ //
385
+ // ── Why this is not seeding from configuration ────────────────────────
386
+ //
387
+ // §13i rules that rows are created **on first launch of a named browser,
388
+ // not from configuration at startup**, so that two processes holding
389
+ // different lists only ever create rows for browsers somebody actually
390
+ // asked for. This is that rule, at the moment it applies: a claim is what
391
+ // causes a launch — §2.4b puts the launch after the commit — so a granted
392
+ // claim is the first point at which a browser is genuinely going to be
393
+ // started. **No row is created for a browser merely because configuration
394
+ // names it**, and the resolution above has already refused any name the
395
+ // configured lists do not carry, so nothing here can mint a row for a
396
+ // browser nobody configured.
397
+ //
398
+ // ── Why the race needs nothing new ────────────────────────────────────
399
+ //
400
+ // §1.2a already arbitrates the launch race through this same transaction —
401
+ // *"one row, one winner"*. Two processes claiming the same new browser at
402
+ // once are serialised here like every other writer, and `OR IGNORE` makes
403
+ // the loser's insert a no-op rather than an error: the row it wanted
404
+ // exists, which is the outcome it was asking for.
405
+ //
406
+ // The kind is written from the list the name was found in, which is the
407
+ // only place it is known — the name itself carries no kind once names are
408
+ // configured, which is why the column exists (schema step nine).
409
+ const configuredKind = settings.privateBrowsers.includes(browserId) ? 'private' : 'regular';
410
+ db.prepare("INSERT OR IGNORE INTO browsers (id, kind, state) VALUES (@id, @kind, 'stopped')").run({ id: browserId, kind: configuredKind });
411
+ // **A browser being signed into is not available, and this is what makes
412
+ // that state mean anything** (§5.5.1 step 2: "From that moment, requests
413
+ // for it are refused with a retry hint").
414
+ //
415
+ // ── Why refused here, and why refused rather than queued ──────────────
416
+ //
417
+ // The position is load-bearing in both directions. It is **after** the
418
+ // argument checks, because nothing about a sign-in makes a malformed
419
+ // request valid — a caller naming a browser that does not exist should hear
420
+ // about the name, not about somebody's sign-in. It is **before** the
421
+ // admission arithmetic and before any row is written, so a refused caller
422
+ // is not charged capacity, holds no key, and has nothing to release: the
423
+ // same position, and the same reason, as the two refusals above it.
424
+ //
425
+ // Refused rather than queued because §2.2 draws that line by what the queue
426
+ // promises: **the queue's promise is that capacity frees up**, and a browser
427
+ // handed to a person is not a capacity shortage. Queuing here would give a
428
+ // caller a place in a line whose length says nothing about how long a human
429
+ // takes to type a password. So it is `retryable`, with the hint on the
430
+ // sentence, and the caller decides when to come back.
431
+ //
432
+ // **Queued callers already waiting are untouched** — this reads the browser
433
+ // row and writes nothing, so §5.5.1's "queued callers keep their places and
434
+ // their timers" holds by there being no code here that could take one.
435
+ const browserState = db
436
+ .prepare('SELECT state, kind FROM browsers WHERE id = @id')
437
+ .get({ id: browserId });
438
+ // **§7.1 `claim.browser_kind_agrees`, and §13i's one nameable exception**
439
+ // (`DECISIONS.md` §13i: "every disagreement is a nameable refusal rather
440
+ // than a silently broken invariant"). `INSERT OR IGNORE` above is a no-op
441
+ // when a row for this name already exists, whatever kind it was created
442
+ // with — that is what makes it safe under the launch race (§1.2a), and
443
+ // also exactly what makes it unable to tell "the row I wanted already
444
+ // exists" from "a row with this name but a different kind already exists".
445
+ // A second process that configured this name under the other kind would
446
+ // otherwise be handed a browser whose profile persists (or does not) in a
447
+ // way its own configuration never asked for, in total silence. This reads
448
+ // the row back — inside the same transaction, so nothing can change it
449
+ // between the insert and this check — and refuses by name rather than
450
+ // adopting it.
451
+ if (browserState !== undefined && browserState.kind !== configuredKind) {
452
+ scope.recordRefusal({
453
+ kind: 'claim_requested',
454
+ outcome: 'deny',
455
+ guard: 'claim.browser_kind_agrees',
456
+ adapter,
457
+ sessionId: input.sessionId,
458
+ detail: { browser: browserId, storedKind: browserState.kind, configuredKind },
459
+ });
460
+ throw new CallRefusal('browser_kind_mismatch', `The ${browserId} browser is recorded as ${browserState.kind} but this process has it configured as ${configuredKind}. Another process on this machine holds a different configuration for the same name; reconcile the two rather than retrying, because nothing here changes with time.`, { detail: { browser: browserId, storedKind: browserState.kind, configuredKind } });
461
+ }
462
+ if (browserState?.state === 'signing-in') {
463
+ scope.recordRefusal({
464
+ kind: 'claim_requested',
465
+ outcome: 'deny',
466
+ guard: 'browser.serving',
467
+ adapter,
468
+ sessionId: input.sessionId,
469
+ detail: { browser: browserId, state: browserState.state },
470
+ });
471
+ throw new CallRefusal('browser_unavailable', `The ${browserId} browser is being signed into by a person right now, so it is not serving callers. This is a pause rather than a fault: it will serve again as soon as they are finished, and a place in the queue would not help because nothing here is waiting on capacity. Try again shortly.`, { detail: { browser: browserId, state: browserState.state } });
472
+ }
473
+ const now = swept.sweptAt;
474
+ const claimId = randomUUID();
475
+ const key = mintKey();
476
+ // Admission: one integer against one integer, over a count this same
477
+ // transaction reconciled (§7.1 `capacity.admission`). This is the whole of
478
+ // the capacity model — there is no request size to add and no reservation
479
+ // to arithmetic against.
480
+ const granted = admits(countActiveClaims(db), settings.tabBudget);
481
+ const ttlSeconds = granted ? settings.leaseSeconds : settings.queueSeconds;
482
+ // The arrival counter, allocated inside this transaction and therefore
483
+ // serialised with every other caller by construction (§1.0a). It is what
484
+ // orders the queue: `created_at` has millisecond resolution and callers
485
+ // arriving inside one millisecond are ordinary at this rate, so ordering by
486
+ // it would leave the order between them to a random identifier — and a
487
+ // position decided that way can get *worse*, which §2.5 promises it never
488
+ // does.
489
+ const arrival = db
490
+ .prepare('UPDATE claim_arrival SET next = next + 1 WHERE only_row = 1 RETURNING next')
491
+ .get().next;
492
+ db.prepare(`INSERT INTO claims
493
+ (id, key_hash, session_id, browser_id, state, purpose,
494
+ expires_at, ttl_seconds, activated_at, arrival, created_at, updated_at)
495
+ VALUES
496
+ (@id, @keyHash, @sessionId, @browserId, @state, @purpose,
497
+ strftime('%Y-%m-%dT%H:%M:%fZ', @now, @extend), @ttl, @activatedAt, @arrival, @now, @now)`).run({
498
+ arrival,
499
+ id: claimId,
500
+ keyHash: hashKey(key),
501
+ sessionId: input.sessionId,
502
+ browserId,
503
+ state: granted ? 'active' : 'queued',
504
+ purpose: input.purpose,
505
+ now,
506
+ extend: `+${String(ttlSeconds)} seconds`,
507
+ ttl: ttlSeconds,
508
+ // Null forever on a lease that expired while waiting, and set at the
509
+ // moment a lease stops waiting (§1.3). A queued lease has never had a tab.
510
+ activatedAt: granted ? now : null,
511
+ });
512
+ append(db, {
513
+ kind: 'claim_requested',
514
+ outcome: 'allow',
515
+ adapter,
516
+ claimId,
517
+ sessionId: input.sessionId,
518
+ browserId,
519
+ detail: { purpose: input.purpose, granted },
520
+ });
521
+ return granted
522
+ ? grant({ scope, input, settings, claimId, key, browserId, now, storageSeed })
523
+ : // A queued lease has no tab, so there is nothing to seed into and the
524
+ // entries are deliberately dropped rather than held. §2.5 is why: a
525
+ // place that is promoted is promoted by a **fresh** call carrying the
526
+ // key, and holding a caller's credential across an unbounded wait to
527
+ // replay it later is a longer custody than this service should have.
528
+ queue({ scope, input, settings, claimId, key, browserId, now });
529
+ }
530
+ /**
531
+ * The grant: the lease is active, so it has a tab.
532
+ *
533
+ * **The tab row is created `opening` and no browser is called.** §2.4b is
534
+ * absolute about this — a round trip to a browser inside the transaction lets
535
+ * one wedged browser block every arbitration call on the machine, because
536
+ * every caller is serialised behind the same writer. `opening` is the honest
537
+ * state for a tab the tool has been asked for and has not answered about.
538
+ *
539
+ * **Capacity is taken by the claim row, not by the tab** (§2.3): the claim
540
+ * *is* the capacity, so there is no window in which capacity is reserved for
541
+ * a tab that does not exist yet. If the tab fails to open, the lease ends and
542
+ * the count follows immediately, because it is a count of claims.
543
+ */
544
+ function grant(branch) {
545
+ const { scope, input, settings, claimId, key, browserId, now } = branch;
546
+ const storageSeed = branch.storageSeed ?? [];
547
+ const tabId = randomUUID();
548
+ scope.db
549
+ .prepare(`INSERT INTO tabs (id, claim_id, browser_id, state, created_at, updated_at)
550
+ VALUES (@tabId, @claimId, @browserId, 'opening', @now, @now)`)
551
+ .run({ tabId, claimId, browserId, now });
552
+ const expiresAt = readExpiry(scope, claimId);
553
+ append(scope.db, {
554
+ kind: 'claim_granted',
555
+ outcome: 'allow',
556
+ adapter: scope.adapter,
557
+ claimId,
558
+ tabId,
559
+ sessionId: input.sessionId,
560
+ browserId,
561
+ detail: { expiresAt, leaseSeconds: settings.leaseSeconds },
562
+ });
563
+ append(scope.db, {
564
+ kind: 'tab_opening',
565
+ outcome: 'allow',
566
+ adapter: scope.adapter,
567
+ claimId,
568
+ tabId,
569
+ sessionId: input.sessionId,
570
+ browserId,
571
+ });
572
+ if (storageSeed.length > 0) {
573
+ // **Origins and keys, never values** (§3.2). The redaction is
574
+ // `seedRecord`'s and it is structural — the type it returns has no field
575
+ // a value could live in — so this call site cannot leak one by being
576
+ // written carelessly, and a later edit here cannot either.
577
+ //
578
+ // ── What this row asserts, and what it deliberately does not ──────────
579
+ //
580
+ // **It records that a seed was ACCEPTED, not that storage was written**,
581
+ // and it stays that way now that the write exists. The claim path still
582
+ // cannot do the writing: it runs inside the arbitration transaction, and
583
+ // §2.4b keeps browser work outside it — the tab is a row in `opening`
584
+ // with no page behind it yet.
585
+ //
586
+ // What changed is that there is now a **second** row. `pageFor` opens the
587
+ // page on the tab's first use, seeds it before any navigation, and
588
+ // appends `seed: 'applied'` once the driver has returned
589
+ // (`operations/pages.ts`). So the pair reads as a request and its
590
+ // outcome, and a lease that was granted a seed which never reached a
591
+ // browser has the first row and not the second.
592
+ //
593
+ // That asymmetry is the point. A row saying the lease *started life
594
+ // holding a credential* on the strength of the ask alone would assert
595
+ // something the system may not have done — a ledger that overstates is
596
+ // worse than one that is silent, because the question §3.2 wants answered
597
+ // is a security question and a false negative in it is read as an
598
+ // all-clear. `requested` is the true fact available at this point;
599
+ // `applied` is the true fact available at the other one.
600
+ append(scope.db, {
601
+ kind: 'storage_seeded',
602
+ outcome: 'allow',
603
+ adapter: scope.adapter,
604
+ claimId,
605
+ tabId,
606
+ sessionId: input.sessionId,
607
+ browserId,
608
+ detail: { entries: seedRecord(storageSeed), seed: 'requested' },
609
+ });
610
+ }
611
+ // A granted caller may still be its own obstacle — it has just taken the
612
+ // last unit and the rest of its work is now queued behind other callers.
613
+ // §2.3a scopes the nudge to a refusal or a queue placement, so nothing is
614
+ // attached here; the ledger already records the grant.
615
+ return {
616
+ value: {
617
+ outcome: 'granted',
618
+ claimId,
619
+ key,
620
+ browserId,
621
+ tabId,
622
+ expiresAt,
623
+ leaseSeconds: settings.leaseSeconds,
624
+ storageSeed,
625
+ },
626
+ };
627
+ }
628
+ /**
629
+ * The queue placement: a lease and a key, and no tab.
630
+ *
631
+ * The response carries the position, a weak estimate labelled as one, and —
632
+ * the substance of `MILESTONES.md` #17 rather than its wording — **the
633
+ * mechanism**: check back at just under the lifetime, because a check
634
+ * scheduled exactly at the deadline races the sweep and loses about half the
635
+ * time.
636
+ */
637
+ function queue(branch) {
638
+ const { scope, input, settings, claimId, key, browserId, now } = branch;
639
+ const position = queuePosition(scope.db, claimId);
640
+ const estimate = waitEstimateSeconds(scope.db, position);
641
+ const expiresAt = readExpiry(scope, claimId);
642
+ const checkBack = checkBackSeconds(settings.queueSeconds);
643
+ append(scope.db, {
644
+ kind: 'claim_queued',
645
+ outcome: 'allow',
646
+ adapter: scope.adapter,
647
+ claimId,
648
+ sessionId: input.sessionId,
649
+ browserId,
650
+ detail: { position, expiresAt, queueSeconds: settings.queueSeconds, queuedAt: now },
651
+ });
652
+ const nudge = nudgeIfOwnObstacle(scope.db, {
653
+ sessionId: input.sessionId,
654
+ claimId,
655
+ adapter: scope.adapter,
656
+ outcome: 'queued',
657
+ });
658
+ return {
659
+ value: {
660
+ outcome: 'queued',
661
+ claimId,
662
+ key,
663
+ browserId,
664
+ position,
665
+ queueSeconds: settings.queueSeconds,
666
+ expiresAt,
667
+ ...(estimate === undefined ? {} : { waitEstimateSeconds: estimate }),
668
+ checkBackSeconds: checkBack,
669
+ checkBack: `Call in with this key at least every ${String(checkBack)} seconds, or this place is lost and a fresh request queues at the back with a new key. ` +
670
+ `The place lives ${String(settings.queueSeconds)} seconds; set up a recurring check at ${String(checkBack)} seconds rather than at the deadline, because a check made exactly at the deadline races the reclamation and loses about half the time. ` +
671
+ 'Any call carrying this key extends the place — asking where you stand is how you hold it.',
672
+ ...(nudge === undefined ? {} : { nudge }),
673
+ },
674
+ };
675
+ }
676
+ /**
677
+ * Read the expiry back out of the row rather than recomputing it.
678
+ *
679
+ * The value was written by the database's own clock arithmetic, and computing
680
+ * the same instant a second time in this process would produce a string that
681
+ * agrees under one set of rounding rules and disagrees under another. What
682
+ * the caller is told is therefore read back from the row, so it is exactly
683
+ * what the sweep will compare against.
684
+ */
685
+ function readExpiry(scope, claimId) {
686
+ const row = scope.db
687
+ .prepare('SELECT expires_at AS expiresAt FROM claims WHERE id = @claimId')
688
+ .get({
689
+ claimId,
690
+ });
691
+ return row.expiresAt;
692
+ }