@bytesbrains/cruise-worker 0.0.0-stage → 0.12.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 (137) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +37 -2
  3. package/index.d.ts +14 -0
  4. package/index.mjs +41 -0
  5. package/migrations/0001_virtual_keys.sql +102 -0
  6. package/migrations/0002_seed_projects.sql +16 -0
  7. package/migrations/0003_budgets.sql +43 -0
  8. package/migrations/0004_tenant_isolation_and_integer_money.sql +49 -0
  9. package/migrations/0005_model_prices.sql +42 -0
  10. package/migrations/0006_budget_reservation.sql +38 -0
  11. package/migrations/0007_routing_table.sql +93 -0
  12. package/migrations/0008_measurement_provenance.sql +24 -0
  13. package/migrations/0009_conformance.sql +37 -0
  14. package/migrations/0010_conformance_key.sql +20 -0
  15. package/migrations/0011_correct_workers_ai_price.sql +30 -0
  16. package/migrations/0012_rate_limits.sql +28 -0
  17. package/migrations/0013_deepseek_and_mistral.sql +42 -0
  18. package/migrations/0014_cost_ledger.sql +104 -0
  19. package/migrations/0015_memberships.sql +33 -0
  20. package/migrations/0016_latency.sql +23 -0
  21. package/migrations/0017_ledger_provider_index.sql +8 -0
  22. package/migrations/0018_fallback_chains.sql +34 -0
  23. package/migrations/0019_rate_buckets.sql +27 -0
  24. package/migrations/0020_mistral_price_and_pin.sql +62 -0
  25. package/migrations/0021_mistral_price_date_is_utc.sql +35 -0
  26. package/migrations/0022_drop_unenforced_key_budget.sql +32 -0
  27. package/migrations/0023_model_limits.sql +48 -0
  28. package/migrations/0024_conditional_prices.sql +84 -0
  29. package/migrations/0025_catalogue_from_keyed_hosts.sql +172 -0
  30. package/migrations/0026_lanes.sql +83 -0
  31. package/migrations/0027_lane_alternatives.sql +36 -0
  32. package/migrations/0028_lanes_tenant_scope.sql +66 -0
  33. package/migrations/0029_lane_jobs.sql +146 -0
  34. package/migrations/0030_lane_requires.sql +38 -0
  35. package/migrations/0031_tenant_billable.sql +15 -0
  36. package/migrations/0032_cache_hit_billing.sql +35 -0
  37. package/migrations/0033_sell_markup.sql +37 -0
  38. package/migrations/0034_tiers_and_lane_quotas.sql +38 -0
  39. package/migrations/0035_wallet_scope.sql +68 -0
  40. package/migrations/0036_credit_grants.sql +53 -0
  41. package/migrations/0037_reconciliation.sql +45 -0
  42. package/migrations/0038_balance_service_keys.sql +35 -0
  43. package/migrations/0039_deepseek_peak_is_weekdays.sql +77 -0
  44. package/migrations/0040_glm_5_3_on_workers_ai.sql +63 -0
  45. package/migrations/0041_mistral_sweep_2026_08_29.sql +89 -0
  46. package/migrations/0042_provision_service_keys.sql +50 -0
  47. package/migrations/0043_tier_project_quota.sql +32 -0
  48. package/migrations/0044_audit_tenant_scope.sql +49 -0
  49. package/migrations/0045_audit_backfill_derivable.sql +42 -0
  50. package/migrations/0046_measurement_note.sql +24 -0
  51. package/migrations/0047_ledger_max_tokens.sql +21 -0
  52. package/migrations/0048_price_observations.sql +140 -0
  53. package/migrations/0049_openai_and_gemini.sql +239 -0
  54. package/migrations/0050_meta_direct_upstream.sql +127 -0
  55. package/migrations/0051_upstream_credential_and_operator.sql +74 -0
  56. package/migrations/0052_muse_spark_1_3.sql +71 -0
  57. package/migrations/0053_anthropic_groq_and_xai.sql +172 -0
  58. package/migrations/0054_tier_service_keys.sql +60 -0
  59. package/migrations/0055_output_defaults.sql +79 -0
  60. package/migrations/0056_model_modality.sql +37 -0
  61. package/migrations/0057_openai_image_models.sql +177 -0
  62. package/migrations/0058_price_components.sql +230 -0
  63. package/migrations/0059_speech.sql +151 -0
  64. package/migrations/0060_embeddings.sql +131 -0
  65. package/migrations/0061_data_retention_backfill.sql +121 -0
  66. package/migrations/0062_gemini_embedding_disabled.sql +125 -0
  67. package/migrations/0063_google_provider_path_prefix.sql +59 -0
  68. package/migrations/0064_economics_service_keys.sql +51 -0
  69. package/migrations/0065_lane_selection.sql +66 -0
  70. package/migrations/0066_tenant_default_selection.sql +42 -0
  71. package/migrations/0067_graduated_selection.sql +103 -0
  72. package/migrations/0068_cache_scope.sql +73 -0
  73. package/migrations/0069_lane_cache_settings.sql +50 -0
  74. package/migrations/0070_lane_member_quality_rank.sql +98 -0
  75. package/migrations/0071_key_cache_pool.sql +60 -0
  76. package/migrations/0072_cache_digest.sql +51 -0
  77. package/migrations/0073_lane_max_chain.sql +51 -0
  78. package/migrations/0074_hunter_builder_lanes.sql +149 -0
  79. package/migrations/0075_hunter_builder_frontline.sql +36 -0
  80. package/migrations/0076_deepseek_flash.sql +86 -0
  81. package/migrations/0077_gpt_6_astra.sql +35 -0
  82. package/migrations/0078_astra_deep_reasoning.sql +20 -0
  83. package/migrations/0079_gemini_flash_lite_extraction.sql +15 -0
  84. package/migrations/0080_priced_by.sql +24 -0
  85. package/migrations/0081_grok_imagine.sql +81 -0
  86. package/migrations/0082_markup_three_percent.sql +32 -0
  87. package/migrations/0083_markup_per_tenant_and_tier.sql +57 -0
  88. package/migrations/0084_coding_lanes_frontier.sql +93 -0
  89. package/migrations/0085_minute_period_and_demo_charge.sql +77 -0
  90. package/migrations/0086_project_sentry.sql +41 -0
  91. package/migrations/0087_lane_router.sql +71 -0
  92. package/migrations/0088_openai_frontier_coding_lanes.sql +54 -0
  93. package/migrations/0089_agentic_coding_drops_codestral.sql +44 -0
  94. package/migrations/0090_board_followups_2026_10.sql +196 -0
  95. package/migrations/0091_opus_5_5_coding_lanes.sql +43 -0
  96. package/migrations/0092_deepseek_peak_holidays.sql +83 -0
  97. package/migrations/0093_tool_choice_forced.sql +26 -0
  98. package/migrations/0094_board_followups_2026_10_08.sql +115 -0
  99. package/migrations/0095_request_compression.sql +43 -0
  100. package/migrations/0096_lane_compress.sql +36 -0
  101. package/migrations/0097_tenant_default_compress.sql +34 -0
  102. package/migrations/0098_ledger_session.sql +47 -0
  103. package/migrations/0099_wallet_low_water_backfill.sql +47 -0
  104. package/migrations/0100_rag_grounded_answers_lane.sql +92 -0
  105. package/migrations/0101_perplexity_agent_api.sql +125 -0
  106. package/migrations/0102_web_search_job_and_flag.sql +137 -0
  107. package/migrations/0103_openai_web_search_price.sql +44 -0
  108. package/migrations/0104_web_search_lane.sql +82 -0
  109. package/migrations/0105_web_search_lane_billed.sql +22 -0
  110. package/migrations/0106_reservations_journal.sql +64 -0
  111. package/migrations/0107_session_cap.sql +65 -0
  112. package/migrations/0108_ledger_attempts.sql +41 -0
  113. package/migrations/0109_gemini_retention.sql +39 -0
  114. package/migrations/0110_gemini_4_argon.sql +110 -0
  115. package/migrations/0111_gemini_4_argon_lanes.sql +80 -0
  116. package/migrations/0112_ledger_affinity.sql +42 -0
  117. package/migrations/0113_contributor_lane_namespace.sql +125 -0
  118. package/migrations/0114_meta_contributor_models.sql +55 -0
  119. package/migrations/0115_oss_chat_assistant_lane.sql +41 -0
  120. package/migrations/0116_promo_credits.sql +63 -0
  121. package/migrations/0117_oss_mirror_lanes.sql +85 -0
  122. package/migrations/0118_project_management_job.sql +112 -0
  123. package/migrations/0119_oss_project_management_lane.sql +29 -0
  124. package/migrations/0120_moonshot_k3_1_pro.sql +107 -0
  125. package/migrations/0121_jobs.sql +100 -0
  126. package/migrations/0122_batch_prices.sql +188 -0
  127. package/migrations/0123_batch_files.sql +50 -0
  128. package/migrations/0124_batch_run.sql +21 -0
  129. package/migrations/0125_anthropic_path_prefix.sql +22 -0
  130. package/package.json +37 -4
  131. package/worker/284906b51fc0a3173ea7d301234907f30e834f54-cruise-mark-64.png +0 -0
  132. package/worker/2c39de526314484775fdfc503fac50c437d21e4e-cruise-logo-240.png +0 -0
  133. package/worker/63fb63e2d8ae6d8b68228768ef6c91ab8f0c0100-cruise-logo-480.png +0 -0
  134. package/worker/VERSION +1 -0
  135. package/worker/b23bfbc792e8710d3b74284a0a1b8ab044c18897-logo-dark.png +0 -0
  136. package/worker/index.js +31451 -0
  137. package/wrangler.template.jsonc +48 -0
@@ -0,0 +1,47 @@
1
+ -- Every capped wallet gets the low-water mark a signup wallet starts with (#583).
2
+ --
3
+ -- ## Why
4
+ --
5
+ -- `readWallet` reports `low` only where `soft_micros` is set. Signup wallets
6
+ -- have carried $1 since `walletInsert` (`src/tenants-prepaid.ts`), but a tenant
7
+ -- made before prepaid signup got its wallet from its first credit grant, and
8
+ -- that insert (`capRecompute`, `src/payments.ts`) never wrote one. Read on
9
+ -- 2026-10-09 (UTC), that is exactly two wallets: `bb-adm` on production and
10
+ -- `bytesbrains` on staging. Both read `ok` until they read `exhausted`, and
11
+ -- `bb-adm` did it to an agent tenant's Builder run on call 49, six minutes in
12
+ -- (agents#822). Staging's read `ok` at $0.0495, which cannot cover
13
+ -- one request.
14
+ --
15
+ -- ## Why overwriting NULL overrides nobody
16
+ --
17
+ -- Nothing has ever set a wallet's threshold after creation: no route, no CLI.
18
+ -- `soft_micros` on a tenant row is written by `walletInsert` (always $1) and
19
+ -- now by the grant's insert (the same), and by nothing else. So a NULL here
20
+ -- means "never set", never "chosen to be none". Should a route to choose one
21
+ -- arrive, its NULL would be a decision, and this migration has already run.
22
+ --
23
+ -- ## Scope
24
+ --
25
+ -- Capped wallets only (`hard_micros IS NOT NULL`). An uncapped wallet has no
26
+ -- balance to be low on, and `walletCannotWarn` (`src/doctor-wallet.ts`) asks
27
+ -- about capped ones for the same reason. `updated_by` says who wrote it, so
28
+ -- the row carries its own reason; `updated_at` moves with it.
29
+ --
30
+ -- ## The demo
31
+ --
32
+ -- It runs there too, where the prepaid backfill deliberately does not
33
+ -- (`docs/provisioning.md`). Harmless: the demo makes no wallets (`prepaidOn`)
34
+ -- and holds none, so the `WHERE` matches nothing.
35
+ --
36
+ -- ## Order
37
+ --
38
+ -- Independent of the Worker: it reads `soft_micros` already. Before or after
39
+ -- the deploy, `low` appears from the moment this runs.
40
+
41
+ UPDATE budgets
42
+ SET soft_micros = 1000000,
43
+ updated_by = 'migration:0099 (#583)',
44
+ updated_at = datetime('now')
45
+ WHERE scope = 'tenant'
46
+ AND soft_micros IS NULL
47
+ AND hard_micros IS NOT NULL;
@@ -0,0 +1,92 @@
1
+ -- `bb/rag-grounded-answers`: the platform lane for answers over retrieved
2
+ -- passages (#627).
3
+ --
4
+ -- The vocabulary has carried `rag-grounded-answers` since 0029, and the job
5
+ -- CHECK already admits it, so this is rows and no rebuild. It was deferred
6
+ -- because "no gate distinguishes it from `chat-assistant` with a long window"
7
+ -- (docs/vocabulary.md). That stopped being true when `json_schema` became a
8
+ -- measured flag: `bb/extraction` has required it since 0030, and production's
9
+ -- 2026-10-08 conformance run measured it on every member below. The lane's
10
+ -- promise is the conjunction the job needs and `chat-assistant` does not
11
+ -- make — room for many retrieved chunks *and* an answer whose citations parse.
12
+ --
13
+ -- ## Requirements
14
+ --
15
+ -- long-context:200000 — a retrieval context is the long input. 131,072 (the
16
+ -- bare constraint's default) admits the 128k models,
17
+ -- which a top-k of a few dozen 2-4k chunks plus the
18
+ -- conversation fills; 200k is the vocabulary's own
19
+ -- worked example and every member clears 262,144.
20
+ -- json-schema — grounded answers are cited answers, and a citation
21
+ -- list a client cannot parse is not one.
22
+ --
23
+ -- Not `tools-required`: the retrieval happened before the request, in the
24
+ -- caller's store. The issue's `strict_faithfulness` has no column and no
25
+ -- conformance check, so it is not a constraint here — an unmeasurable promise
26
+ -- is the thing "unknown capabilities fail closed" exists to refuse
27
+ -- (src/lanes.ts). A judged faithfulness check is #627's follow-up, not a row.
28
+ --
29
+ -- ## Aliases: `bb/rag-search` and `bb/retrieval` (#627)
30
+ --
31
+ -- The id is the canonical vocabulary job (0029's CHECK: a global lane's id is
32
+ -- `bb/` || job). Callers requesting `bb/rag-search` or `bb/retrieval` (from #627
33
+ -- and the video masterclass) are resolved directly to `bb/rag-grounded-answers`
34
+ -- at admission (`src/admission-resolve.ts`).
35
+ --
36
+ -- The companion `bb/web-search` is tracked separately: no catalogued model is
37
+ -- currently measured to search on AI Gateway `/compat` without native agent
38
+ -- wire protocols (see #629 for Perplexity Agent API integration).
39
+ -- That work has its own dedicated upstream issues.
40
+ --
41
+ -- Nothing here enables a search: Cruise injects no tool into a lane request,
42
+ -- so a member answers from the passages it was sent.
43
+ --
44
+ -- ## Members
45
+ --
46
+ -- Every one measured on production 2026-10-08 (Gemini 2026-09-30): json_schema
47
+ -- = 1, streaming = 1, tools = 1, max_context >= 262,144 — so the lane's
48
+ -- streaming capability, the intersection, stays true. Five hosts, so one
49
+ -- host's outage leaves four. Rates per million in / out, from the migration
50
+ -- each row's price came from:
51
+ --
52
+ -- anthropic/claude-haiku-5-5 $0.10 / $0.50 ($0.50 / $2.50 over 100K — 0094)
53
+ -- workers-ai/@cf/zai-org/glm-5.3-flash $0.15 / $0.50
54
+ -- mistral/mistral-small-latest $0.15 / $0.60
55
+ -- openai/gpt-5.6-luna $0.20 / $1.20 ($0.40 / $1.80 over 272K)
56
+ -- google-ai-studio/gemini-3.5-flash-lite $0.30 / $2.50
57
+ -- workers-ai/@cf/deepseek-ai/deepseek-v4-flash-0731 $0.44 / $1.32
58
+ --
59
+ -- Haiku's long-prompt row is the one a RAG request most often lands on, and
60
+ -- the lane's price is the maximum across variants (src/lanes.ts), so the
61
+ -- catalogue quotes $0.50 / $2.50 for it rather than the headline.
62
+ --
63
+ -- Left out: Sonnet 5.5 and Mistral Medium, more than ten times the cheapest
64
+ -- member (`DEAR_FACTOR`, src/lanes-warnings.ts) — under `cheap` they would
65
+ -- serve only when every cheaper member is refused, which is when a bill
66
+ -- surprises someone. GLM-5.3, Gemini 3.8 Flash, Gemma 4, Nemotron and
67
+ -- Kimi K2.6 clear both requirements but measured streaming = 0, and one of
68
+ -- them would make the lane refuse every streamed request in the catalogue's
69
+ -- picture of it.
70
+ --
71
+ -- `cheap`, the default, as for `bb/summarization`: saver is the vocabulary's
72
+ -- default posture and grounded answering is not the job where it is wrong
73
+ -- (that is `deep-reasoning`). quality_rank 1 is best, declared rather than
74
+ -- measured, as 0070's are. cache_ttl_seconds stays 0, as for every lane
75
+ -- (0069): a retrieval store changes under a fixed question, so a cached answer
76
+ -- is the caller's call, by `x-cruise-cache: max-age=…`.
77
+
78
+ INSERT INTO lanes (id, job, description) VALUES
79
+ ('bb/rag-grounded-answers', 'rag-grounded-answers',
80
+ 'Answers grounded in passages the caller retrieved and sent: a long context in, a cited answer out. No web access; the model answers from what it was given.');
81
+
82
+ INSERT INTO lane_members (lane_id, model_id, position, quality_rank) VALUES
83
+ ('bb/rag-grounded-answers', 'anthropic/claude-haiku-5-5', 1, 1),
84
+ ('bb/rag-grounded-answers', 'openai/gpt-5.6-luna', 2, 1),
85
+ ('bb/rag-grounded-answers', 'google-ai-studio/gemini-3.5-flash-lite', 3, 2),
86
+ ('bb/rag-grounded-answers', 'workers-ai/@cf/deepseek-ai/deepseek-v4-flash-0731', 4, 2),
87
+ ('bb/rag-grounded-answers', 'mistral/mistral-small-latest', 5, 2),
88
+ ('bb/rag-grounded-answers', 'workers-ai/@cf/zai-org/glm-5.3-flash', 6, 3);
89
+
90
+ INSERT INTO lane_requires (lane_id, constraint_, value) VALUES
91
+ ('bb/rag-grounded-answers', 'long-context', '200000'),
92
+ ('bb/rag-grounded-answers', 'json-schema', NULL);
@@ -0,0 +1,125 @@
1
+ -- Perplexity, through the Agent API (#629, from #207 and 0090).
2
+ --
3
+ -- 0058 priced `perplexity/sonar*` per request and 0090 recorded that Sonar's
4
+ -- `/chat/completions` endpoint ended on 2026-09-27, so the next person to add
5
+ -- Perplexity "starts from the Agent API". This is that start. Every figure
6
+ -- read 2026-10-11 UTC from Perplexity's own pages, cited per row.
7
+ --
8
+ -- ## What the Agent API is, and why this catalogues one model
9
+ --
10
+ -- `POST https://api.perplexity.ai/v1/agent` takes the OpenAI **Responses**
11
+ -- shape, and it is mostly a resale counter: its model list is OpenAI,
12
+ -- Anthropic, Google, xAI, Z.AI, Moonshot and NVIDIA models at those labs'
13
+ -- rates. `docs/upstreams.md` decided 2026-09-03 that Cruise buys each lab
14
+ -- direct and reaches a reseller only for what cannot be got direct, so none of
15
+ -- those rows are here. What only Perplexity sells is **`perplexity/sonar`**
16
+ -- and **its web search** — and those are this migration.
17
+ --
18
+ -- **`sonar-pro` and `sonar-reasoning-pro` are not on the Agent API.** #629
19
+ -- names both; Perplexity's model page lists one Perplexity model, `sonar`, and
20
+ -- maps Sonar Pro's behaviour to its `low` *preset* — a Perplexity-managed
21
+ -- bundle of another lab's model ("Presets are not pinned to a specific
22
+ -- version"), which a measured catalogue row cannot describe. 0058's rows for
23
+ -- the two stay as they are: they price a model nothing routes, as they did.
24
+ --
25
+ -- ## The upstream: direct, not through the gateway
26
+ --
27
+ -- `kind = 'provider'` with a `base_url`, the shape 0050 built for Meta. The
28
+ -- gateway's `/compat` route translates chat completions, and the chat endpoint
29
+ -- it would translate to is the one that ended. The adapter
30
+ -- (`src/chat-perplexity-agent.ts`) builds the Agent API body itself and names
31
+ -- `/agent` rather than the `/responses` alias, because the same host serves a
32
+ -- different, tool-less Orchestrator API at `/router/v1/responses`.
33
+ -- The id is `perplexity` so 0058's component rows, already keyed
34
+ -- `perplexity/sonar`, name this model; the secret is `PROVIDER_KEY_PERPLEXITY`.
35
+ INSERT INTO upstreams (id, name, kind, base_url) VALUES
36
+ ('perplexity', 'Perplexity', 'provider', 'https://api.perplexity.ai/v1');
37
+
38
+ -- ── The model ─────────────────────────────────────────────────────────────────
39
+ -- https://docs.perplexity.ai/docs/agent-api/models
40
+ --
41
+ -- `upstream_model` keeps the prefix: the Agent API's ids are `provider/model`,
42
+ -- so Perplexity's own id for this model is `perplexity/sonar`.
43
+ --
44
+ -- **No context window and no output cap are published** — not on the model
45
+ -- page, and `GET /v1/models` answers with id and pricing alone. So the window
46
+ -- is **measured, as a lower bound**: on 2026-10-12 UTC the Agent API served a
47
+ -- 300,028-token prompt to this model (status `completed`, $0.075 charged), and
48
+ -- 300,000 is written. A bound below the truth refuses a prompt that would have
49
+ -- fit; one above it sends a prompt the host will refuse — so the error is put
50
+ -- on the side that costs a caller a retry rather than a failed upstream call.
51
+ -- Raise it when a page or a probe says more. `max_output` stays NULL — 0011's
52
+ -- rule, never write a number you did not fetch — so a billed caller sends
53
+ -- `max_tokens` (`request_unbounded` otherwise).
54
+ --
55
+ -- **`data_retention` is `retained`**, read 2026-10-12 from two of Perplexity's
56
+ -- own pages, which have to be read together:
57
+ --
58
+ -- https://docs.perplexity.ai/docs/resources/faq — "We do not retain any
59
+ -- query data sent through the API and do not train on any of your data."
60
+ --
61
+ -- https://docs.perplexity.ai/api-reference/agent-post — `store`: "When
62
+ -- false, the response is hidden from later retrieve calls ... It can still
63
+ -- be used as a `previous_response_id` continuation source." Responses with
64
+ -- `store` omitted can be retrieved by id.
65
+ --
66
+ -- The second contradicts "do not retain" for the Agent API as plainly as it
67
+ -- can: a response retrievable later, or usable as a continuation later, is
68
+ -- held somewhere. So not `none`; and not trained on, which both pages agree
69
+ -- leaves `retained` (the same reading 0061 gave xAI). The adapter sends
70
+ -- `store: false`, which narrows who can read it back and does not change the
71
+ -- answer. The Privacy & Security page's zero-retention promise names "the
72
+ -- Chat Completions API" — the endpoint that ended — and is not cited for that
73
+ -- reason. The API terms on perplexity.ai answer 403 to a fetch, as xAI's did.
74
+ INSERT INTO models (id, upstream_id, upstream_model, max_context, max_output,
75
+ data_retention, data_retention_source, data_retention_as_of) VALUES
76
+ ('perplexity/sonar', 'perplexity', 'perplexity/sonar', 300000, NULL,
77
+ 'retained', 'https://docs.perplexity.ai/api-reference/agent-post', '2026-10-12');
78
+
79
+ -- $0.25 in, $2.50 out, $0.0625 cache read, per million tokens.
80
+ INSERT INTO price_observations
81
+ (observed_at, model, effective_from, prompt_tokens_from, peak_utc,
82
+ input_micros_per_mtok, output_micros_per_mtok, cached_input_micros_per_mtok, source, note)
83
+ VALUES
84
+ ('2026-10-11 00:00:00', 'perplexity/sonar', '2026-10-11', 0, '',
85
+ 250000, 2500000, 62500, 'https://docs.perplexity.ai/docs/agent-api/models',
86
+ 'First token observation: Sonar on the Agent API (#629, 0101).');
87
+
88
+ -- ── The components: the request fee ends, the search fee begins ───────────────
89
+ -- https://docs.perplexity.ai/docs/getting-started/pricing
90
+ --
91
+ -- **The request fee is zero, written as a row.** 0058's `request` grid for
92
+ -- `perplexity/sonar` ($5/$8/$12 per thousand, by search context size) was
93
+ -- Sonar chat's, and the Agent API has neither the fee nor the size: its
94
+ -- migration guide maps `search_context_size` to "no named-size equivalent".
95
+ -- Left alone those rows would stay in force and charge every Agent API
96
+ -- request a fee nobody bills. A component is in force per model and
97
+ -- component at its latest effective date (`componentRowsInForceFor`), so one
98
+ -- '' row at zero, dated today, supersedes the whole grid — and 0058's own
99
+ -- comment is the reason it is a row and not a deletion: "a host that waives a
100
+ -- fee on one variant says so with a row rather than with an absence".
101
+ --
102
+ -- **Search is per invocation.** $2.50 per thousand for standard search, $1.00
103
+ -- with `search_type: "fast"`. Standard is the '' row because it is what the
104
+ -- host does when the tool names no type ("otherwise uses `web`"); a body
105
+ -- naming `web` finds no exact row and takes '', which is the same rate.
106
+ --
107
+ -- The count is settled from `usage.tool_calls_details.search_web.invocation`
108
+ -- and is **not bounded by anything a request can say**: the Agent API's
109
+ -- request schema has `max_steps` and no per-tool ceiling. So a request that
110
+ -- lets Sonar search is refused a billed tenant as `request_unbounded` and
111
+ -- served to an internal one at the flat reservation (`pricing-components.ts`,
112
+ -- `uncapped_by_host`). A request with no search tool does not search, has no
113
+ -- search to bound, and is served to anyone.
114
+ INSERT INTO price_components
115
+ (observed_at, model, effective_from, component, variant, micros, per, source, note)
116
+ VALUES
117
+ ('2026-10-11 00:00:00', 'perplexity/sonar', '2026-10-11', 'request', '', 0, 1,
118
+ 'https://docs.perplexity.ai/docs/getting-started/pricing',
119
+ 'Agent API: no per-request fee. Supersedes 0058''s Sonar chat grid, which ended with that endpoint on 2026-09-27.'),
120
+ ('2026-10-11 00:00:00', 'perplexity/sonar', '2026-10-11', 'search', '', 2500000, 1000,
121
+ 'https://docs.perplexity.ai/docs/getting-started/pricing',
122
+ 'web_search, standard (search_type web, the default): $2.50 per 1,000 invocations.'),
123
+ ('2026-10-11 00:00:00', 'perplexity/sonar', '2026-10-11', 'search', 'fast', 1000000, 1000,
124
+ 'https://docs.perplexity.ai/docs/getting-started/pricing',
125
+ 'web_search with search_type fast: $1.00 per 1,000 invocations.');
@@ -0,0 +1,137 @@
1
+ -- A `web-search` job, and a measured `web_search` flag (#627).
2
+ --
3
+ -- #627 asks for two search lanes. The RAG half shipped as
4
+ -- `bb/rag-grounded-answers` (0100); this is the first half of the other:
5
+ -- the word, and the measurement a lane on it will gate on. No lane is created
6
+ -- here — a `bb/` lane needs three routable, measured members, and until a
7
+ -- conformance run has written this flag no model has it.
8
+ --
9
+ -- ## The job: ahead of the board, as `hunter` and `builder` were
10
+ --
11
+ -- The model board (`naderu.com/data/model-board.json`, read 2026-10-12) has no
12
+ -- web-search job: it files hosted search under `rag-grounded-answers`, as a
13
+ -- price line beside retrieval. The two are different work for a router —
14
+ -- retrieval answers over passages the caller sends, web search goes and gets
15
+ -- them — and different bills: a search is a per-invocation fee (0058, 0101),
16
+ -- a retrieved passage is input tokens. So Cruise names it now, the way 0074
17
+ -- named `hunter` and `builder` (#409), and a later board rename is a
18
+ -- migration, the same as 0029's `bulk-extraction` → `extraction`.
19
+ --
20
+ -- ## Why a rebuild, and the window it has
21
+ --
22
+ -- SQLite cannot widen a CHECK, and `lanes.job` carries the closed set. 0067
23
+ -- refused a rebuild for `selection_policy` and chose expand-and-contract;
24
+ -- that is not available here, because the global-lane CHECK pins
25
+ -- `id = 'bb/' || job` and no added column can make `job` hold a word its own
26
+ -- CHECK refuses. So this is 0074's shape, for 0074's reason, over the schema
27
+ -- as it stands now — 0087's `router` and `router_input`, 0096's `compress`.
28
+ --
29
+ -- **The window is real and is stated rather than argued small**: each
30
+ -- statement is its own transaction on D1, so between `DROP TABLE lanes` and
31
+ -- the `RENAME` a lane lookup finds no table and fails. Run it at the quietest
32
+ -- hour, and expect a handful of lane requests to 500 if traffic lands inside.
33
+ -- Pinned-model requests do not read `lanes` and are unaffected.
34
+ --
35
+ -- ## The flag
36
+ --
37
+ -- A column like 0093's, tri-state, written only by conformance
38
+ -- (`web_search`, `src/conformance.ts`). The check sends a `web_search` tool
39
+ -- and passes only when the host's **usage reports a search count** — the
40
+ -- field `pricing-components.ts` settles the `search` component from — so
41
+ -- "can search" and "can be billed for searching" are one observation, and a
42
+ -- host that searches without saying so measures absent rather than free.
43
+ --
44
+ -- NULL fails closed: `src/capabilities.ts` refuses a request carrying a
45
+ -- search tool for a model nobody has checked. **So the rollout order is
46
+ -- load-bearing, as 0093's was**: this migration, then a conformance run (the
47
+ -- conformance key is exempt from the gate, and the Worker that predates this
48
+ -- passes the tool through), then the Worker that gates on it.
49
+
50
+ ALTER TABLE models ADD COLUMN web_search INTEGER CHECK (web_search IN (0, 1));
51
+
52
+ CREATE TABLE lanes_new (
53
+ id TEXT PRIMARY KEY,
54
+ tenant_id TEXT REFERENCES tenants(id),
55
+ job TEXT NOT NULL CHECK (job IN (
56
+ 'chat-assistant', 'deep-reasoning', 'summarization', 'translation',
57
+ 'content-writing', 'extraction', 'code-completion', 'agentic-coding',
58
+ 'hunter', 'builder', 'web-search',
59
+ 'code-review', 'document-understanding', 'speech', 'rag-grounded-answers',
60
+ 'computer-use', 'content-moderation', 'other')),
61
+ description TEXT NOT NULL,
62
+ enabled INTEGER NOT NULL DEFAULT 1,
63
+ created_by TEXT NOT NULL DEFAULT 'migration',
64
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
65
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
66
+ archived_at TEXT,
67
+ selection TEXT NOT NULL DEFAULT 'cheap'
68
+ CHECK (selection IN ('cheap', 'random', 'weighted')),
69
+ selection_policy TEXT NOT NULL DEFAULT 'cheap'
70
+ CHECK (selection_policy IN ('cheap', 'random', 'weighted', 'graduated')),
71
+ cache_ttl_seconds INTEGER NOT NULL DEFAULT 0
72
+ CHECK (cache_ttl_seconds BETWEEN 0 AND 2592000),
73
+ cache_scope TEXT NOT NULL DEFAULT 'model'
74
+ CHECK (cache_scope IN ('model', 'lane')),
75
+ max_chain INTEGER
76
+ CHECK (max_chain IS NULL OR max_chain BETWEEN 1 AND 3),
77
+ router TEXT
78
+ CHECK (router IS NULL OR router IN ('jev')),
79
+ router_input TEXT NOT NULL DEFAULT 'hint'
80
+ CHECK (router_input IN ('hint', 'prompt')),
81
+ compress INTEGER NOT NULL DEFAULT 0
82
+ CHECK (compress IN (0, 1) AND (compress = 0 OR tenant_id IS NOT NULL)),
83
+ CHECK (
84
+ (tenant_id IS NULL AND substr(id, 1, 3) = 'bb/') OR
85
+ (tenant_id IS NOT NULL AND substr(id, 1, length(tenant_id) + 1) = tenant_id || '/'
86
+ AND substr(id, 1, 3) <> 'bb/')
87
+ ),
88
+ CHECK (tenant_id IS NOT NULL OR (id = 'bb/' || job AND job <> 'other'))
89
+ );
90
+
91
+ INSERT INTO lanes_new (
92
+ id, tenant_id, job, description, enabled, created_by, created_at, updated_at,
93
+ archived_at, selection, selection_policy, cache_ttl_seconds, cache_scope, max_chain,
94
+ router, router_input, compress
95
+ )
96
+ SELECT id, tenant_id, job, description, enabled, created_by, created_at, updated_at,
97
+ archived_at, selection, selection_policy, cache_ttl_seconds, cache_scope, max_chain,
98
+ router, router_input, compress
99
+ FROM lanes;
100
+
101
+ CREATE TABLE lane_members_new (
102
+ lane_id TEXT NOT NULL REFERENCES lanes_new(id),
103
+ model_id TEXT NOT NULL REFERENCES models(id),
104
+ position INTEGER NOT NULL,
105
+ weight REAL NOT NULL DEFAULT 1 CHECK (weight > 0),
106
+ quality_rank INTEGER,
107
+ PRIMARY KEY (lane_id, model_id)
108
+ );
109
+
110
+ INSERT INTO lane_members_new (lane_id, model_id, position, weight, quality_rank)
111
+ SELECT lane_id, model_id, position, weight, quality_rank FROM lane_members;
112
+
113
+ CREATE TABLE lane_requires_new (
114
+ lane_id TEXT NOT NULL REFERENCES lanes_new(id),
115
+ constraint_ TEXT NOT NULL CHECK (constraint_ IN (
116
+ 'long-context', 'tools-required', 'json-schema', 'vision-required',
117
+ 'no-training-on-data',
118
+ 'low-latency', 'high-throughput', 'open-weights', 'data-residency',
119
+ 'audio-required', 'batch-api')),
120
+ value TEXT,
121
+ PRIMARY KEY (lane_id, constraint_)
122
+ );
123
+
124
+ INSERT INTO lane_requires_new (lane_id, constraint_, value)
125
+ SELECT lane_id, constraint_, value FROM lane_requires;
126
+
127
+ DROP INDEX idx_lane_members_lane;
128
+ DROP INDEX idx_lanes_tenant;
129
+ DROP TABLE lane_requires;
130
+ DROP TABLE lane_members;
131
+ DROP TABLE lanes;
132
+ ALTER TABLE lanes_new RENAME TO lanes;
133
+ ALTER TABLE lane_members_new RENAME TO lane_members;
134
+ ALTER TABLE lane_requires_new RENAME TO lane_requires;
135
+
136
+ CREATE INDEX idx_lane_members_lane ON lane_members (lane_id, position);
137
+ CREATE INDEX idx_lanes_tenant ON lanes (tenant_id, enabled);
@@ -0,0 +1,44 @@
1
+ -- OpenAI's web search fee, for the six chat models Cruise routes (#627).
2
+ --
3
+ -- Production conformance measured every OpenAI chat model `web_search = 0`:
4
+ -- Chat Completions takes a search tool and searches nothing. The search is the
5
+ -- Responses API's built-in `web_search` tool, which `src/chat-openai-search.ts`
6
+ -- now sends, so a request that searches can be counted — and a count with no
7
+ -- rate in force fails closed rather than billing, so the rate lands first.
8
+ --
9
+ -- ## The rate
10
+ --
11
+ -- Read 2026-10-12 UTC from https://developers.openai.com/api/docs/pricing
12
+ -- (`platform.openai.com/docs/pricing` 301s there): "Web search — all models —
13
+ -- $10.00 / 1k calls + Search content tokens billed at model rates." The page
14
+ -- names `gpt-5` and the o-series as reasoning models and does not list the
15
+ -- 5.6 or 6.x lines by name; "all models" is the row that covers them, and the
16
+ -- $25 / 1k preview rate is for non-reasoning models only, which none of these
17
+ -- six is (each refuses function tools on Chat Completions, #545).
18
+ --
19
+ -- **The count is `tool_usage.web_search.num_requests`**, measured live the
20
+ -- same day on `gpt-5.6-luna`: 1 for one search, 4 for four. A call the host
21
+ -- began and `max_tool_calls` stopped (`status: "searching"`) was not counted,
22
+ -- so the billed number is that field and not the number of `web_search_call`
23
+ -- output items.
24
+ --
25
+ -- **The search content tokens are already billed**, by the token columns:
26
+ -- one search read back as 8,496 input tokens against a ~20-token prompt. That
27
+ -- is why `reservation.ts` cannot bound a search (`search_input_unbounded`), and
28
+ -- nothing here changes it — this row bounds and settles the fee only.
29
+ INSERT INTO price_components
30
+ (observed_at, model, effective_from, component, variant, micros, per, source, note)
31
+ VALUES
32
+ ('2026-10-12 11:00:00', 'openai/gpt-5.6-luna', '2026-10-12', 'search', '', 10000000, 1000,
33
+ 'https://developers.openai.com/api/docs/pricing',
34
+ 'First observation (0103): web search, $10 per 1,000 calls, counted from tool_usage.web_search.num_requests.'),
35
+ ('2026-10-12 11:00:00', 'openai/gpt-5.6-terra', '2026-10-12', 'search', '', 10000000, 1000,
36
+ 'https://developers.openai.com/api/docs/pricing', 'First observation (0103).'),
37
+ ('2026-10-12 11:00:00', 'openai/gpt-5.6-sol', '2026-10-12', 'search', '', 10000000, 1000,
38
+ 'https://developers.openai.com/api/docs/pricing', 'First observation (0103).'),
39
+ ('2026-10-12 11:00:00', 'openai/gpt-6-luna', '2026-10-12', 'search', '', 10000000, 1000,
40
+ 'https://developers.openai.com/api/docs/pricing', 'First observation (0103).'),
41
+ ('2026-10-12 11:00:00', 'openai/gpt-6-astra', '2026-10-12', 'search', '', 10000000, 1000,
42
+ 'https://developers.openai.com/api/docs/pricing', 'First observation (0103).'),
43
+ ('2026-10-12 11:00:00', 'openai/gpt-6.1-sol', '2026-10-12', 'search', '', 10000000, 1000,
44
+ 'https://developers.openai.com/api/docs/pricing', 'First observation (0103).');
@@ -0,0 +1,82 @@
1
+ -- `bb/web-search`: the platform lane for answers grounded in a live search of
2
+ -- the open web (#627).
3
+ --
4
+ -- 0102 named the job and the `web_search` flag and created no lane, because a
5
+ -- `bb/` lane needs three routable, measured hosts and none had one. They do
6
+ -- now, each measured on production on 2026-10-12: Perplexity (0101, #630),
7
+ -- Anthropic through the Messages API (#636, run `966ca16c`) and OpenAI through
8
+ -- the Responses API (#643, runs `34c25547`, `656f2f83`, `d1f3f6e6`). Every
9
+ -- check sent one search and read `searches=1` back from the host's usage, which
10
+ -- is the number the `search` component settles from.
11
+ --
12
+ -- ## No requirement row, and why that is not a gap
13
+ --
14
+ -- There is no `web-search` constraint in `lane_requires`, and none is needed.
15
+ -- A request on this lane that carries no search tool is given one before
16
+ -- allocation (`src/lanes-search.ts`), so every request on it asks to search,
17
+ -- and the `web_search` capability gate (`src/capabilities.ts`) refuses a
18
+ -- member not measured able to. A member that stops searching drops out at the
19
+ -- gate the next time conformance writes 0; it is not served a request it
20
+ -- cannot ground. `lanes-warnings.ts` warns on any member without the flag.
21
+ --
22
+ -- ## Billed tenants are refused, on purpose, until a ceiling is measured
23
+ --
24
+ -- **Every request on this lane is `search_input_unbounded` for a billed
25
+ -- tenant** (`src/reservation.ts`, #636). The search fee is bounded — `max_uses`
26
+ -- caps it, `max_tool_calls` on OpenAI — but the results come back as input
27
+ -- tokens, and no host states a ceiling on them: one OpenAI search read back as
28
+ -- 8,496 input tokens against a ~20-token prompt (0103). A guessed ceiling is
29
+ -- not a bound, and a cap that drifts by a guess is not a cap. So the lane
30
+ -- serves internal keys, which reserve the floor, and refuses billed ones with
31
+ -- a message naming the reason. Opening it to billed tenants is a measured
32
+ -- per-search token ceiling, tracked on #654, not a change here.
33
+ --
34
+ -- That also settles the question #627 left open for Perplexity, which cannot
35
+ -- enforce `max_uses` (`searchBoundedByHost`) and so could not be bounded even
36
+ -- for the fee. While every search is unboundable for billed tenants, a
37
+ -- Perplexity member costs them nothing they had; when the ceiling lands, the
38
+ -- reservation will need Perplexity's own bound, and `search.bound` already
39
+ -- returns null for it.
40
+ --
41
+ -- ## Members
42
+ --
43
+ -- The cheapest searching model per host. Rates per million in / out, at the
44
+ -- dearest variant (the lane's price is the maximum, src/lanes.ts), and the
45
+ -- per-search fee:
46
+ --
47
+ -- openai/gpt-6-luna $0.20 / $0.75 $10 / 1k searches (0103)
48
+ -- perplexity/sonar $0.25 / $2.50 $1.00–$2.50 / 1k (0101)
49
+ -- openai/gpt-5.6-luna $0.40 / $1.80 $10 / 1k searches (0103)
50
+ -- anthropic/claude-haiku-5-5 $0.50 / $2.50 $10 / 1k searches (0094)
51
+ --
52
+ -- All four measured streaming = 1, tools = 1, json_schema = 1, so the lane's
53
+ -- capabilities, the intersection, hold all three. Two OpenAI members because
54
+ -- the host is the one with two cheap searching models, and a lane of three
55
+ -- single members loses a third of itself to one outage.
56
+ --
57
+ -- Sonar's 300,000-token window is the lane's (`narrow_member`); the others
58
+ -- take a million. A search question is short and the results are the host's
59
+ -- to fit, so the narrower window costs the job nothing, and dropping Sonar
60
+ -- would cost it a host.
61
+ --
62
+ -- Left out: Haiku 4.5, dearer than Haiku 5.5 for an older model; every
63
+ -- Sonnet, Opus, Fable, and the 5.6/6.x Sol, Terra and Astra lines, at least
64
+ -- four times the cheapest member and past `DEAR_FACTOR` for most — a search
65
+ -- answer is a summary of what was found, and the job does not need a
66
+ -- frontier model to write it. A tenant who wants one names it, or builds a
67
+ -- lane.
68
+ --
69
+ -- `cheap`, the default, as for `bb/rag-grounded-answers`. quality_rank 1 is
70
+ -- best, declared rather than measured, as 0070's are. cache_ttl_seconds stays
71
+ -- 0 here: a short TTL for trending queries is #627's cache item, a separate
72
+ -- change with its own billing question.
73
+
74
+ INSERT INTO lanes (id, job, description) VALUES
75
+ ('bb/web-search', 'web-search',
76
+ 'Answers grounded in a live search of the open web, billed per search. A request that sends no search tool is given one, capped at one search. Internal keys only until a per-search input ceiling is measured.');
77
+
78
+ INSERT INTO lane_members (lane_id, model_id, position, quality_rank) VALUES
79
+ ('bb/web-search', 'openai/gpt-6-luna', 1, 1),
80
+ ('bb/web-search', 'anthropic/claude-haiku-5-5', 2, 1),
81
+ ('bb/web-search', 'perplexity/sonar', 3, 2),
82
+ ('bb/web-search', 'openai/gpt-5.6-luna', 4, 2);
@@ -0,0 +1,22 @@
1
+ -- `bb/web-search` serves billed tenants (#654).
2
+ --
3
+ -- 0104 seeded the lane with a description saying it was for internal keys
4
+ -- only, because a request that searched could not be bounded: the results are
5
+ -- billed as input tokens and no host states a ceiling on them. #654 bounds them
6
+ -- at the one ceiling a host cannot exceed, the context window, once per search
7
+ -- `max_uses` allows (`src/reservation-search.ts`). The lane injects
8
+ -- `max_uses: 1`, so a plain request on it now has a bound, and the reservation
9
+ -- holds it — about $0.21 on `openai/gpt-6-luna`, $0.50 on Haiku 5.5 — and
10
+ -- settles to what was used.
11
+ --
12
+ -- `perplexity/sonar` stays a member and serves internal keys. A billed key
13
+ -- skips it: its host does not enforce `max_uses` (#630), so the bound has no
14
+ -- `k` to multiply by (`searchUnboundableFor`).
15
+ --
16
+ -- The description is all that changes. It is what `/v1/models` shows a caller,
17
+ -- and a caller reading "internal keys only" would not try.
18
+
19
+ UPDATE lanes
20
+ SET description = 'Answers grounded in a live search of the open web, billed per search. A request that sends no search tool is given one, capped at one search. A billed key reserves one context window of input per allowed search, settled to what was used, and is not routed to a member whose host cannot enforce the cap.',
21
+ updated_at = datetime('now')
22
+ WHERE id = 'bb/web-search';
@@ -0,0 +1,64 @@
1
+ -- The reservations journal: every hold that has not yet settled (#594).
2
+ --
3
+ -- A request is charged in two steps. The reserve is durable, a D1 write before
4
+ -- the upstream call; the settle runs only inside the invocation that served the
5
+ -- request, in the stream drain or in `waitUntil` after it. When that invocation
6
+ -- dies between the two (a client gone past `waitUntil`'s grace, a deploy during
7
+ -- traffic: both production leaks since v0.9.2 fall on deploy days), nothing
8
+ -- recorded that a hold was still open. The counter kept the whole hold, the
9
+ -- ledger got no row, and `spend_vs_ledger` reported drift every day from
10
+ -- 2026-09-17 with zero recorded failures (#562, #597, #598).
11
+ --
12
+ -- So a hold is now a row. It is written in the same batch as the reserve, and
13
+ -- only when the reserve moved the counter (`changes()`, `src/reservation-journal.ts`):
14
+ -- a refused request leaves none. It is deleted in the same batch as the settle
15
+ -- or the release, and the counter correction in that batch is conditional on
16
+ -- the row still existing and unclaimed. The daily sweep claims it in a single
17
+ -- statement before writing anything. Whichever of the two gets the row first is
18
+ -- the one that adjusts the counter and writes the ledger row, so a request is
19
+ -- never settled twice.
20
+ --
21
+ -- A row the sweep finds older than the threshold is a request whose invocation
22
+ -- died. It becomes a ledger row charged the whole hold, which is the same rule
23
+ -- as `no_usage`: nothing reported what was used, so the counter is left where
24
+ -- the reserve put it, and the ledger is brought up to it (decided on #594).
25
+ --
26
+ -- Deliberately no `tenant_id`: a project belongs to one tenant, and the sweep
27
+ -- reads it from `projects` (migration 0004's one path). `wallet_held` says
28
+ -- whether the tenant's lifetime row held a share, which is all a release needs.
29
+ --
30
+ -- The ledger fields are the ones a settled row would carry and that are known
31
+ -- at admission. `model` is the first candidate the chain would try: for a lane
32
+ -- the member that was allocated, for a pinned model the model itself, for a
33
+ -- cache hit the model that produced the cached answer.
34
+ CREATE TABLE reservations (
35
+ id TEXT PRIMARY KEY,
36
+ at TEXT NOT NULL DEFAULT (datetime('now')),
37
+ project_id TEXT NOT NULL,
38
+ reserved_micros INTEGER NOT NULL CHECK (reserved_micros >= 0),
39
+ period TEXT NOT NULL,
40
+ -- The window the reserve charged, by the same expression it used, so a
41
+ -- release names exactly the row the hold went to (#347).
42
+ period_start TEXT NOT NULL,
43
+ wallet_held INTEGER NOT NULL DEFAULT 0 CHECK (wallet_held IN (0, 1)),
44
+ model TEXT NOT NULL,
45
+ upstream_id TEXT NOT NULL,
46
+ requested_model TEXT NOT NULL,
47
+ key_prefix TEXT NOT NULL,
48
+ traffic_class TEXT NOT NULL,
49
+ lane TEXT NOT NULL,
50
+ account TEXT NOT NULL,
51
+ streamed INTEGER NOT NULL DEFAULT 0,
52
+ -- Set by the sweep's first statement, which is its claim. The sweep runs
53
+ -- from the admin CLI, whose batch is sequential rather than atomic
54
+ -- (`scripts/d1-http.ts`), so it cannot rely on one batch to beat a settle:
55
+ -- it claims the row in one statement, and from then on a settle, which only
56
+ -- touches rows with this NULL, stands down. A sweep that dies after the
57
+ -- claim leaves the row for the next one to finish.
58
+ swept_at TEXT
59
+ );
60
+
61
+ -- The sweep reads the oldest rows first; a project's open holds are what an
62
+ -- exact `spend_vs_ledger` would add to its ledger sum.
63
+ CREATE INDEX idx_reservations_at ON reservations (at);
64
+ CREATE INDEX idx_reservations_project ON reservations (project_id, period_start);