@miller-tech/uap 2.21.3 → 2.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/README.md +22 -10
  2. package/config/model-profiles/qwen38-coder390.json +46 -0
  3. package/config/model-profiles/qwen38.json +3 -2
  4. package/config/model-registry.json +110 -0
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/benchmarks/paired/types.d.ts +2 -2
  7. package/dist/bin/cli.js +315 -20
  8. package/dist/bin/cli.js.map +1 -1
  9. package/dist/cli/attention.d.ts +6 -0
  10. package/dist/cli/attention.d.ts.map +1 -0
  11. package/dist/cli/attention.js +115 -0
  12. package/dist/cli/attention.js.map +1 -0
  13. package/dist/cli/bench-report.d.ts +82 -0
  14. package/dist/cli/bench-report.d.ts.map +1 -0
  15. package/dist/cli/bench-report.js +237 -0
  16. package/dist/cli/bench-report.js.map +1 -0
  17. package/dist/cli/clients.d.ts +6 -0
  18. package/dist/cli/clients.d.ts.map +1 -0
  19. package/dist/cli/clients.js +142 -0
  20. package/dist/cli/clients.js.map +1 -0
  21. package/dist/cli/dashboard-grants.d.ts +26 -0
  22. package/dist/cli/dashboard-grants.d.ts.map +1 -0
  23. package/dist/cli/dashboard-grants.js +69 -0
  24. package/dist/cli/dashboard-grants.js.map +1 -0
  25. package/dist/cli/dashboard-pace.d.ts +31 -0
  26. package/dist/cli/dashboard-pace.d.ts.map +1 -0
  27. package/dist/cli/dashboard-pace.js +42 -0
  28. package/dist/cli/dashboard-pace.js.map +1 -0
  29. package/dist/cli/dashboard-session.d.ts +25 -0
  30. package/dist/cli/dashboard-session.d.ts.map +1 -0
  31. package/dist/cli/dashboard-session.js +814 -0
  32. package/dist/cli/dashboard-session.js.map +1 -0
  33. package/dist/cli/dashboard-types.d.ts +18 -0
  34. package/dist/cli/dashboard-types.d.ts.map +1 -0
  35. package/dist/cli/dashboard-types.js +9 -0
  36. package/dist/cli/dashboard-types.js.map +1 -0
  37. package/dist/cli/dashboard.d.ts +2 -18
  38. package/dist/cli/dashboard.d.ts.map +1 -1
  39. package/dist/cli/dashboard.js +43 -793
  40. package/dist/cli/dashboard.js.map +1 -1
  41. package/dist/cli/deliver.d.ts +35 -0
  42. package/dist/cli/deliver.d.ts.map +1 -1
  43. package/dist/cli/deliver.js +93 -9
  44. package/dist/cli/deliver.js.map +1 -1
  45. package/dist/cli/diff.d.ts +85 -0
  46. package/dist/cli/diff.d.ts.map +1 -0
  47. package/dist/cli/diff.js +293 -0
  48. package/dist/cli/diff.js.map +1 -0
  49. package/dist/cli/eta.d.ts +35 -0
  50. package/dist/cli/eta.d.ts.map +1 -0
  51. package/dist/cli/eta.js +101 -0
  52. package/dist/cli/eta.js.map +1 -0
  53. package/dist/cli/handsfree.d.ts +7 -0
  54. package/dist/cli/handsfree.d.ts.map +1 -1
  55. package/dist/cli/handsfree.js +27 -0
  56. package/dist/cli/handsfree.js.map +1 -1
  57. package/dist/cli/hooks.d.ts.map +1 -1
  58. package/dist/cli/hooks.js +126 -5
  59. package/dist/cli/hooks.js.map +1 -1
  60. package/dist/cli/loops.d.ts +24 -5
  61. package/dist/cli/loops.d.ts.map +1 -1
  62. package/dist/cli/loops.js +92 -12
  63. package/dist/cli/loops.js.map +1 -1
  64. package/dist/cli/models.d.ts +62 -0
  65. package/dist/cli/models.d.ts.map +1 -0
  66. package/dist/cli/models.js +567 -0
  67. package/dist/cli/models.js.map +1 -0
  68. package/dist/cli/plan.d.ts +9 -0
  69. package/dist/cli/plan.d.ts.map +1 -1
  70. package/dist/cli/plan.js +57 -4
  71. package/dist/cli/plan.js.map +1 -1
  72. package/dist/cli/proxy-relay.d.ts +55 -0
  73. package/dist/cli/proxy-relay.d.ts.map +1 -0
  74. package/dist/cli/proxy-relay.js +116 -0
  75. package/dist/cli/proxy-relay.js.map +1 -0
  76. package/dist/cli/proxy.d.ts +4 -0
  77. package/dist/cli/proxy.d.ts.map +1 -1
  78. package/dist/cli/proxy.js +26 -1
  79. package/dist/cli/proxy.js.map +1 -1
  80. package/dist/cli/sandbox.d.ts +101 -4
  81. package/dist/cli/sandbox.d.ts.map +1 -1
  82. package/dist/cli/sandbox.js +393 -44
  83. package/dist/cli/sandbox.js.map +1 -1
  84. package/dist/cli/self.d.ts +13 -0
  85. package/dist/cli/self.d.ts.map +1 -0
  86. package/dist/cli/self.js +94 -0
  87. package/dist/cli/self.js.map +1 -0
  88. package/dist/cli/systemd-services.d.ts +2 -8
  89. package/dist/cli/systemd-services.d.ts.map +1 -1
  90. package/dist/cli/systemd-services.js +5 -20
  91. package/dist/cli/systemd-services.js.map +1 -1
  92. package/dist/cli/task.js +1 -1
  93. package/dist/cli/task.js.map +1 -1
  94. package/dist/cli/wizard-config.d.ts.map +1 -1
  95. package/dist/cli/wizard-config.js +31 -2
  96. package/dist/cli/wizard-config.js.map +1 -1
  97. package/dist/config/settings-registry.d.ts +1 -1
  98. package/dist/config/settings-registry.d.ts.map +1 -1
  99. package/dist/config/settings-registry.js +108 -0
  100. package/dist/config/settings-registry.js.map +1 -1
  101. package/dist/coordination/capability-router.js +2 -2
  102. package/dist/coordination/capability-router.js.map +1 -1
  103. package/dist/coordination/database.d.ts +13 -0
  104. package/dist/coordination/database.d.ts.map +1 -1
  105. package/dist/coordination/database.js +63 -7
  106. package/dist/coordination/database.js.map +1 -1
  107. package/dist/coordination/eta-inject.d.ts +32 -0
  108. package/dist/coordination/eta-inject.d.ts.map +1 -0
  109. package/dist/coordination/eta-inject.js +64 -0
  110. package/dist/coordination/eta-inject.js.map +1 -0
  111. package/dist/coordination/reactor.d.ts.map +1 -1
  112. package/dist/coordination/reactor.js +14 -0
  113. package/dist/coordination/reactor.js.map +1 -1
  114. package/dist/coordination/service.d.ts +18 -8
  115. package/dist/coordination/service.d.ts.map +1 -1
  116. package/dist/coordination/service.js +38 -28
  117. package/dist/coordination/service.js.map +1 -1
  118. package/dist/dashboard/client-registry.d.ts +163 -0
  119. package/dist/dashboard/client-registry.d.ts.map +1 -0
  120. package/dist/dashboard/client-registry.js +549 -0
  121. package/dist/dashboard/client-registry.js.map +1 -0
  122. package/dist/dashboard/controls.d.ts.map +1 -1
  123. package/dist/dashboard/controls.js +33 -4
  124. package/dist/dashboard/controls.js.map +1 -1
  125. package/dist/dashboard/data-seeder.d.ts +10 -7
  126. package/dist/dashboard/data-seeder.d.ts.map +1 -1
  127. package/dist/dashboard/data-seeder.js +31 -75
  128. package/dist/dashboard/data-seeder.js.map +1 -1
  129. package/dist/dashboard/data-service.d.ts +9 -0
  130. package/dist/dashboard/data-service.d.ts.map +1 -1
  131. package/dist/dashboard/data-service.js +32 -3
  132. package/dist/dashboard/data-service.js.map +1 -1
  133. package/dist/dashboard/placement-routes.d.ts +119 -0
  134. package/dist/dashboard/placement-routes.d.ts.map +1 -0
  135. package/dist/dashboard/placement-routes.js +331 -0
  136. package/dist/dashboard/placement-routes.js.map +1 -0
  137. package/dist/dashboard/server.d.ts.map +1 -1
  138. package/dist/dashboard/server.js +408 -2
  139. package/dist/dashboard/server.js.map +1 -1
  140. package/dist/delivery/attention.d.ts +130 -0
  141. package/dist/delivery/attention.d.ts.map +1 -0
  142. package/dist/delivery/attention.js +341 -0
  143. package/dist/delivery/attention.js.map +1 -0
  144. package/dist/delivery/decompose.d.ts +18 -0
  145. package/dist/delivery/decompose.d.ts.map +1 -1
  146. package/dist/delivery/decompose.js +22 -1
  147. package/dist/delivery/decompose.js.map +1 -1
  148. package/dist/delivery/eta-estimate.d.ts +111 -0
  149. package/dist/delivery/eta-estimate.d.ts.map +1 -0
  150. package/dist/delivery/eta-estimate.js +171 -0
  151. package/dist/delivery/eta-estimate.js.map +1 -0
  152. package/dist/delivery/eta.d.ts +23 -0
  153. package/dist/delivery/eta.d.ts.map +1 -0
  154. package/dist/delivery/eta.js +59 -0
  155. package/dist/delivery/eta.js.map +1 -0
  156. package/dist/delivery/orchestrated-mission.d.ts +10 -0
  157. package/dist/delivery/orchestrated-mission.d.ts.map +1 -1
  158. package/dist/delivery/orchestrated-mission.js +15 -1
  159. package/dist/delivery/orchestrated-mission.js.map +1 -1
  160. package/dist/delivery/plan-check.d.ts +7 -1
  161. package/dist/delivery/plan-check.d.ts.map +1 -1
  162. package/dist/delivery/plan-check.js +23 -2
  163. package/dist/delivery/plan-check.js.map +1 -1
  164. package/dist/delivery/run-state.d.ts +1 -1
  165. package/dist/delivery/run-state.d.ts.map +1 -1
  166. package/dist/delivery/run-state.js +8 -1
  167. package/dist/delivery/run-state.js.map +1 -1
  168. package/dist/delivery/task-orchestrator.d.ts +8 -3
  169. package/dist/delivery/task-orchestrator.d.ts.map +1 -1
  170. package/dist/delivery/task-orchestrator.js +75 -43
  171. package/dist/delivery/task-orchestrator.js.map +1 -1
  172. package/dist/delivery/task-sync.d.ts +21 -0
  173. package/dist/delivery/task-sync.d.ts.map +1 -1
  174. package/dist/delivery/task-sync.js +44 -8
  175. package/dist/delivery/task-sync.js.map +1 -1
  176. package/dist/delivery/task-workspace.d.ts +3 -3
  177. package/dist/delivery/task-workspace.js +3 -3
  178. package/dist/generators/self-card.d.ts +65 -0
  179. package/dist/generators/self-card.d.ts.map +1 -0
  180. package/dist/generators/self-card.js +256 -0
  181. package/dist/generators/self-card.js.map +1 -0
  182. package/dist/inference/probe.js +1 -1
  183. package/dist/inference/probe.js.map +1 -1
  184. package/dist/memory/prepopulate.d.ts.map +1 -1
  185. package/dist/memory/prepopulate.js +2 -0
  186. package/dist/memory/prepopulate.js.map +1 -1
  187. package/dist/models/analytics.d.ts +32 -0
  188. package/dist/models/analytics.d.ts.map +1 -1
  189. package/dist/models/analytics.js +68 -5
  190. package/dist/models/analytics.js.map +1 -1
  191. package/dist/models/plan-readability.d.ts +55 -0
  192. package/dist/models/plan-readability.d.ts.map +1 -0
  193. package/dist/models/plan-readability.js +339 -0
  194. package/dist/models/plan-readability.js.map +1 -0
  195. package/dist/placement/admission.d.ts +59 -0
  196. package/dist/placement/admission.d.ts.map +1 -0
  197. package/dist/placement/admission.js +197 -0
  198. package/dist/placement/admission.js.map +1 -0
  199. package/dist/placement/auto.d.ts +44 -0
  200. package/dist/placement/auto.d.ts.map +1 -0
  201. package/dist/placement/auto.js +212 -0
  202. package/dist/placement/auto.js.map +1 -0
  203. package/dist/placement/enforce.d.ts +128 -0
  204. package/dist/placement/enforce.d.ts.map +1 -0
  205. package/dist/placement/enforce.js +655 -0
  206. package/dist/placement/enforce.js.map +1 -0
  207. package/dist/placement/idle.d.ts +50 -0
  208. package/dist/placement/idle.d.ts.map +1 -0
  209. package/dist/placement/idle.js +213 -0
  210. package/dist/placement/idle.js.map +1 -0
  211. package/dist/placement/ledger.d.ts +323 -0
  212. package/dist/placement/ledger.d.ts.map +1 -0
  213. package/dist/placement/ledger.js +263 -0
  214. package/dist/placement/ledger.js.map +1 -0
  215. package/dist/placement/preview.d.ts +51 -0
  216. package/dist/placement/preview.d.ts.map +1 -0
  217. package/dist/placement/preview.js +61 -0
  218. package/dist/placement/preview.js.map +1 -0
  219. package/dist/placement/probes.d.ts +47 -0
  220. package/dist/placement/probes.d.ts.map +1 -0
  221. package/dist/placement/probes.js +207 -0
  222. package/dist/placement/probes.js.map +1 -0
  223. package/dist/placement/proxy-env.d.ts +21 -0
  224. package/dist/placement/proxy-env.d.ts.map +1 -0
  225. package/dist/placement/proxy-env.js +42 -0
  226. package/dist/placement/proxy-env.js.map +1 -0
  227. package/dist/placement/registry.d.ts +1038 -0
  228. package/dist/placement/registry.d.ts.map +1 -0
  229. package/dist/placement/registry.js +274 -0
  230. package/dist/placement/registry.js.map +1 -0
  231. package/dist/policies/liveness.d.ts +6 -8
  232. package/dist/policies/liveness.d.ts.map +1 -1
  233. package/dist/policies/liveness.js +6 -25
  234. package/dist/policies/liveness.js.map +1 -1
  235. package/dist/policies/sandbox-anchor.d.ts +91 -0
  236. package/dist/policies/sandbox-anchor.d.ts.map +1 -0
  237. package/dist/policies/sandbox-anchor.js +187 -0
  238. package/dist/policies/sandbox-anchor.js.map +1 -0
  239. package/dist/policies/sandbox-grants.d.ts +131 -0
  240. package/dist/policies/sandbox-grants.d.ts.map +1 -0
  241. package/dist/policies/sandbox-grants.js +562 -0
  242. package/dist/policies/sandbox-grants.js.map +1 -0
  243. package/dist/state/manifest.d.ts +4 -2
  244. package/dist/state/manifest.d.ts.map +1 -1
  245. package/dist/state/manifest.js +32 -16
  246. package/dist/state/manifest.js.map +1 -1
  247. package/dist/utils/checkout-root.d.ts +2 -0
  248. package/dist/utils/checkout-root.d.ts.map +1 -0
  249. package/dist/utils/checkout-root.js +43 -0
  250. package/dist/utils/checkout-root.js.map +1 -0
  251. package/dist/utils/resolve-uap-bin.d.ts +5 -0
  252. package/dist/utils/resolve-uap-bin.d.ts.map +1 -0
  253. package/dist/utils/resolve-uap-bin.js +33 -0
  254. package/dist/utils/resolve-uap-bin.js.map +1 -0
  255. package/dist/utils/terminal-safe.d.ts +12 -0
  256. package/dist/utils/terminal-safe.d.ts.map +1 -0
  257. package/dist/utils/terminal-safe.js +53 -0
  258. package/dist/utils/terminal-safe.js.map +1 -0
  259. package/docs/ATTRIBUTION.md +1 -0
  260. package/docs/DASHBOARD_UPLIFT_SPEC.md +8 -8
  261. package/docs/INDEX.md +6 -5
  262. package/docs/architecture/OVERVIEW.md +15 -3
  263. package/docs/architecture/PROTOCOL.md +7 -1
  264. package/docs/architecture/adr/0006-operator-directed-model-placement.md +86 -0
  265. package/docs/architecture/adr/0007-auto-displacement-allowlist-as-standing-consent.md +79 -0
  266. package/docs/architecture/adr/0008-fleet-clients-link-out.md +92 -0
  267. package/docs/architecture/adr/0009-completion-triggered-dispatch-and-per-leaf-model-dials.md +71 -0
  268. package/docs/architecture/adr/0010-attention-queue-and-afk-auto-decider.md +159 -0
  269. package/docs/architecture/adr/0011-sandbox-grants-and-network-egress-boundary.md +219 -0
  270. package/docs/architecture/adr/0012-operator-legibility-surfaces.md +175 -0
  271. package/docs/getting-started/INSTALLATION.md +1 -1
  272. package/docs/guides/AUTOMATIC_FEATURES.md +8 -0
  273. package/docs/guides/LOCAL_MODELS.md +1 -1
  274. package/docs/guides/PROXY.md +2 -1
  275. package/docs/guides/SANDBOX.md +91 -4
  276. package/docs/integrations/MCP_ROUTER.md +1 -1
  277. package/docs/plans/model-performance-proof-options.md +176 -0
  278. package/docs/reference/API.md +1 -1
  279. package/docs/reference/CLI.md +169 -4
  280. package/docs/reference/COMPARISON.md +1 -1
  281. package/docs/reference/CONFIGURATION.md +12 -3
  282. package/docs/reference/CONFIGURATION_REFERENCE.md +269 -14
  283. package/docs/reference/DATABASE_SCHEMA.md +1 -1
  284. package/docs/reference/FEATURES.md +15 -3
  285. package/docs/reference/PATTERNS.md +1 -1
  286. package/docs/reference/PLATFORMS.md +14 -1
  287. package/docs/specs/operator-model-placement.md +1314 -0
  288. package/docs/specs/operator-model-placement.review.md +339 -0
  289. package/package.json +2 -2
  290. package/scripts/maintenance/publish-npm.sh +23 -11
  291. package/scripts/version-bump.sh +6 -51
  292. package/src/policies/enforcers/__pycache__/_common.cpython-311.pyc +0 -0
  293. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-311.pyc +0 -0
  294. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-311.pyc +0 -0
  295. package/src/policies/schemas/policies/mandatory-testing-deployment.md +2 -5
  296. package/templates/hooks/__pycache__/deliver_autoroute.cpython-311.pyc +0 -0
  297. package/templates/hooks/session-start.sh +44 -0
  298. package/tools/agents/UAP/cli.py +14 -3
  299. package/tools/agents/UAP/paths.py +50 -0
  300. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-311.pyc +0 -0
  301. package/tools/agents/scripts/anthropic_proxy.py +1386 -99
  302. package/tools/agents/scripts/toolcall_path_normalizer.py +46 -1
  303. package/tools/agents/tests/test_anthropic_proxy_streaming.py +29 -6
  304. package/tools/agents/tests/test_doubling_break.py +64 -5
  305. package/tools/agents/tests/test_error_loop_break.py +10 -4
  306. package/tools/agents/tests/test_error_loop_ignores_correctives.py +13 -2
  307. package/tools/agents/tests/test_lockstep_escalation.py +280 -9
  308. package/tools/agents/tests/test_path_containment.py +94 -0
  309. package/tools/agents/tests/test_proxy_unix_socket.py +162 -0
  310. package/tools/agents/tests/test_signature_quality.py +349 -0
  311. package/tools/agents/tests/test_uap_compliance.py +11 -1
  312. package/web/dash/core.js +1 -0
  313. package/web/dash/tab-clients.js +196 -0
  314. package/web/dash/tab-models.js +387 -8
  315. package/web/dash/tab-policies.js +87 -4
  316. package/web/dash/tabs.js +77 -73
  317. package/web/dashboard.html +6 -0
  318. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  319. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
  320. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
  321. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  322. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  323. package/web/dash/tab-agents.js +0 -21
  324. package/web/dash/tab-deliver.js +0 -12
  325. package/web/dash/tab-memory.js +0 -12
  326. package/web/dash/tab-orchestration.js +0 -21
  327. package/web/dash/tab-overview.js +0 -21
  328. package/web/dash/tab-tasks.js +0 -21
@@ -0,0 +1,1314 @@
1
+ # Operator-Directed Model Placement (MOS + MPC)
2
+
3
+ Status: draft for review. Not implemented.
4
+ Scope: local inference placement — which model sits on which device, who is
5
+ allowed to ask for a different one, and what happens to everyone already
6
+ resident when the answer is no.
7
+
8
+ ## 1. The problem
9
+
10
+ Model choice in UAP is a **deployment-time** decision, not a request-time one.
11
+
12
+ Today the operator picks a backend by hand. `~/.config/uap/model-switch.sh`
13
+ takes one of `flash | exl3 | signal | gsq | ampere | swift | strata`, disables
14
+ every other backend unit, starts the chosen one, and blocks on
15
+ `http://127.0.0.1:8080/health`. The units are mutually exclusive by
16
+ declaration — `uap-strata-server.service` carries:
17
+
18
+ ```
19
+ Conflicts=uap-exl3-server.service uap-llama-server.service uap-ampere-server.service
20
+ uap-swift-server.service uap-signal-server.service uap-gsq-rco-server.service
21
+ uap-flashnext-server.service uap-dflash-server.service
22
+ ```
23
+
24
+ So the card holds exactly one model at a time, and switching it is a manual,
25
+ blind act. Three consequences:
26
+
27
+ 1. **A request can name a model the machine cannot load, and nothing says
28
+ so.** The proxy rewrites the wire model (`_reconcile_wire_model()`,
29
+ `tools/agents/scripts/anthropic_proxy.py:1681`, called at `:15036`) and
30
+ forwards. If the named model is not resident, the request either gets the
31
+ resident model or fails downstream, after the client has already paid the
32
+ round trip.
33
+ 2. **Switching destroys work with no warning.** There is no view of who is
34
+ attached to the resident model, what they are doing, or how much context
35
+ they lose. The operator chooses blind.
36
+ 3. **The cost of loading a second model is unknown at decision time.** The
37
+ numbers exist — but only as prose in profile comments.
38
+ `config/exl3-profiles/qwen38-27b-exl3.env:17` records
39
+ `VRAM resident: 20964 MiB / 24576 MiB`;
40
+ `config/llama-profiles/qwen38-27b-dflash2.env:37` records
41
+ `23449 / 24576 MiB` at `--parallel 2`;
42
+ `config/llama-profiles/gemma4-26b-a4b-mtp.env:38` records
43
+ `~20-21 GB, leaving headroom for the ub2048 compute buffers`.
44
+ Nothing machine-readable consumes them.
45
+
46
+ What is wanted: an agent (coding, video, image) asks UAP for a model path; the
47
+ system answers with what it *can* do on this hardware right now; the operator
48
+ chooses which models load on which devices; and a forced switch shows, before
49
+ it happens, exactly what gets killed or paused.
50
+
51
+ ## 2. What already exists (verified)
52
+
53
+ Do not rebuild these. Placement composes on top of them.
54
+
55
+ | Concern | Existing mechanism |
56
+ | --- | --- |
57
+ | Declared service budgets, headroom, restart budgets | `config/capacity-policy.json`, `src/capacity/policy.ts` |
58
+ | Health semantics, fail-open vs fail-closed probes | `docs/guides/CAPACITY_POLICY.md`, `src/capacity/probe.ts` |
59
+ | Free VRAM probe | `probeGpuFreeMiB()` — **GPU 0 only** |
60
+ | Post-load config verification | `metricsMustMatch` (`{"kv":"int8","max_context":131072}` for `strata-server`) |
61
+ | Slot budget: env → config → probe → default | `src/utils/model-slots.ts` (`DEFAULT_SLOTS = 2`, 30s cache) |
62
+ | Cross-process slot semaphore, AIMD backpressure | `src/utils/model-slot-lease.ts`, `src/coordination/service.ts:1045-1174` |
63
+ | Lease persistence | `model_leases` table, `src/coordination/database.ts` |
64
+ | Local server discovery (no hardcoded port) | `src/utils/llama-discovery.ts` (`ss -ltnp`) |
65
+ | Inference quality, not just liveness | `uap inference health`, `src/inference/strata.ts` |
66
+ | Dashboard write authorization | `mutationAuthorized(req)` in `src/dashboard/server.ts` |
67
+ | Operator's current manual switch | `~/.config/uap/model-switch.sh` |
68
+
69
+ Two gaps that shape the design:
70
+
71
+ - **`model_backpressure` is one row** (`id = 1 CHECK`). Every target shares one
72
+ adaptive limit. Two models on one machine cannot have independent pressure.
73
+ - **`upstream_semaphore` is global**, sized from `PROXY_CONCURRENCY_LIMIT`
74
+ (default `1`). One card-wide gate for every upstream, whatever is resident.
75
+
76
+ ## 3. Decisions taken
77
+
78
+ From the operator, before drafting:
79
+
80
+ 1. **Surface:** a shared core with two front ends — TUI (`uap models`) and a
81
+ dashboard Models tab. Neither is authoritative; both read the same state.
82
+ 2. **Trigger:** the proxy admission gate. Every client is covered — Claude
83
+ Code, Codex, OpenCode, UAP agents — because they all enter through
84
+ `:4000/v1`. A gate in the TypeScript router alone would cover only UAP's
85
+ own callers.
86
+ 3. **Eviction:** prompt first. Never evict without an explicit yes. Auto mode
87
+ may load alongside; it may never displace.
88
+ 4. **Devices:** multi-device abstraction from the start — GPU0, GPU1, CPU
89
+ offload tier, each with its own budget. Single-GPU is the degenerate case,
90
+ not the model.
91
+
92
+ Eight more were settled while reviewing this draft. Each one changed the design
93
+ rather than just the wording:
94
+
95
+ 5. **Registry location:** repo defaults **merged with** machine-local overrides.
96
+ `config/model-registry.json` is the shipped template; `~/.uap/model-registry.json`
97
+ carries this machine's measured numbers and wins on conflict. The measured
98
+ figures in this spec are machine state, so they cannot live only in the repo.
99
+ 6. **Enforcement host:** a TypeScript module mounted on the **dashboard server**,
100
+ reached by the proxy over localhost HTTP. Not a new daemon, because the
101
+ lifecycle helper already starts, reference-counts, and hands the proxy
102
+ `UAP_DASHBOARD_URL`.
103
+ 7. **Alternate configs are not measured now.** Every non-active config ships with
104
+ `status: "unmeasured"` and is refused until `uap models measure` fills it in.
105
+ Measuring them means taking the live backend down.
106
+ 8. **`paused` means offload and reload from disk later.** It is not the proxy's
107
+ existing KV checkpoint, which saves KV to host RAM and frees no VRAM. §4.5
108
+ states what this machine can actually do about that today.
109
+
110
+ ## 4. Architecture
111
+
112
+ ```
113
+ ┌──────────────────────────────────────────┐
114
+ client request ──►│ proxy admission gate (anthropic_proxy.py)│
115
+ │ before _reconcile_wire_model() │
116
+ └───────┬──────────────────────────┬───────┘
117
+ │ resident fits │ needs a decision
118
+ ▼ ▼
119
+ forward upstream POST /api/placement/* (core)
120
+ │
121
+ ┌───────────────────┼──────────────────┐
122
+ ▼ ▼ ▼
123
+ model registry placement ledger enforcement
124
+ (cost, static) (live state, (systemd +
125
+ fail-closed) pending requests) verify)
126
+ │ │ │
127
+ └──── TUI (uap models) + dashboard Models tab
128
+ ```
129
+
130
+ The core is a library (`src/placement/`) hosted by the dashboard server, not a
131
+ new daemon. Decision 2 in §9 forces this: the proxy calls a loopback control
132
+ endpoint, so some process must be alive to answer it, and that process is the
133
+ one that owns the ledger. The CLI talks to the same server over loopback HTTP;
134
+ when the server is down, `uap models status` reads the ledger file directly and
135
+ says so, but `apply` refuses rather than racing a live writer. No new
136
+ long-lived process: the dashboard server is already running, and the proxy is
137
+ already the only component that sees every request.
138
+
139
+ ### 4.1 Model registry — `config/model-registry.json`
140
+
141
+ Machine-readable cost per **config**, replacing the prose in profile comments.
142
+ Two files, merged. `config/model-registry.json` in the repo carries the shape:
143
+ devices, models, config names, advertised ids, task affinity. `~/.uap/model-registry.json`
144
+ on the machine carries the measurements, because a footprint is a property of
145
+ *this hardware*, not of the repo, and a second box with a different card must
146
+ not inherit this box's numbers. The machine-local file overrides per config; a
147
+ config that appears only in the repo file is `unmeasured`. `uap models validate`
148
+ prints which file each entry came from, so a wrong number is at least
149
+ attributable.
150
+
151
+ Cost is per config, not per model: the same weights cost 21.8 GiB under Strata
152
+ with `--kv-resident 32768` and a different amount under a different pack or
153
+ context size. The entry below is **measured on this machine**, not estimated:
154
+
155
+ ```json
156
+ {
157
+ "version": 1,
158
+ "devices": {
159
+ "gpu0": {
160
+ "kind": "gpu", "name": "NVIDIA GeForce RTX 3090", "total_mib": 24576,
161
+ "reserved_mib": 2048,
162
+ "reserve_reason": "1516 MiB attributed to desktop compute processes (Xorg 1227, Chrome 214, Steam 75) + ~900-1000 MiB of graphics memory that --query-compute-apps does not attribute to any PID; permanent while the desktop is up"
163
+ },
164
+ "cpu0": {
165
+ "kind": "cpu", "total_mib": 126944, "reserved_mib": 8192,
166
+ "reserve_reason": "page cache and the TS side must survive a 52 GiB engine attach"
167
+ }
168
+ },
169
+ "models": {
170
+ "qwen3.8-flash-next": {
171
+ "display": "Qwen3.8 Flash Next",
172
+ "engine": "strata",
173
+ "unit": "uap-strata-server",
174
+ "launch": "/home/cogtek/dev/strata/run-iq3_xxs.sh",
175
+ "endpoint": "http://127.0.0.1:8080",
176
+ "rails": 1,
177
+ "rails_source": "metrics:expert_slots",
178
+ "task_affinity": ["code", "general"],
179
+ "affinity": { "device": ["gpu0"] },
180
+ "configs": {
181
+ "strata-iq3_s": {
182
+ "resident_gpu_mib": 21812,
183
+ "host_rss_mib": 52857,
184
+ "context_pool_cells": 131072,
185
+ "per_session_cap_cells": 114688,
186
+ "kv_resident_cells": 32768,
187
+ "kv_mib_per_1k_cells": 12.2,
188
+ "measured_at": "2026-10-08T14:02:00Z",
189
+ "measured_on": "gpu0",
190
+ "flags": ["--max-context", "131072", "--kv", "int8", "--kv-resident", "32768", "--vram-reserve-mib", "1200"]
191
+ }
192
+ },
193
+ "advertises": ["qwen3.8-flash-next-iq3_s", "gpt-5.1-codex"]
194
+ },
195
+ "qwen3.8-27b": {
196
+ "display": "Qwen3.8 27B",
197
+ "engine": "llama.cpp",
198
+ "unit": "uap-llama-server",
199
+ "profile": "config/llama-profiles/qwen38-27b-mtp.env",
200
+ "endpoint": "http://127.0.0.1:8080",
201
+ "task_affinity": ["code", "general"],
202
+ "affinity": { "device": ["gpu0"] },
203
+ "configs": {
204
+ "llama-mtp": { "status": "unmeasured" },
205
+ "exl3-4bit": { "status": "unmeasured" },
206
+ "gsq-rco": { "status": "unmeasured" }
207
+ }
208
+ }
209
+ }
210
+ }
211
+ ```
212
+
213
+ The second entry is the honest state of every alternate on this machine: the
214
+ units exist, the profiles exist, the VRAM prose exists in the profile comments,
215
+ and none of it is machine-readable. They ship as `status: "unmeasured"` and stay
216
+ that way until `uap models measure` fills them in, which requires taking the
217
+ active backend down.
218
+
219
+ `resident_gpu_mib` is the whole-config footprint attributed to the **engine's own
220
+ PID** (`nvidia-smi --query-compute-apps=pid,used_memory`), taken with the server
221
+ idle. It is not weights plus overhead: as §4.1.1 shows, this engine streams
222
+ expert weights through an elastic VRAM cache, so there is no honest weights
223
+ number to add to an overhead number. Admission cost is the resident figure plus
224
+ marginal KV beyond the pool the config already paid for at startup:
225
+
226
+ ```
227
+ cost(config, cells) = resident_gpu_mib
228
+ + max(0, cells - kv_resident_cells) / 1000 × kv_mib_per_1k_cells
229
+ ```
230
+
231
+ For the live config, `cells ≤ 32768` costs exactly 21,812 MiB — the KV pool is
232
+ already resident. Requesting the full 114,688-cell session cap costs
233
+ 21,812 + 81,920/1000 × 12.2 ≈ **22,810 MiB**, which exceeds the card. That is
234
+ not a rounding error; it is the reason the profile caps sessions at 114,688
235
+ against a 131,072 pool and why the pool itself is the constraint the operator
236
+ is really trading.
237
+
238
+ Rules:
239
+
240
+ - **Unknown cost fails closed.** A config with no `measured_at` is not
241
+ placeable. It may be served if already resident; it may never be loaded, and
242
+ it never enters an eviction candidate set. Guessing a footprint is how a
243
+ 24 GiB card gets OOM-killed. `status: "unmeasured"` is the shipped default for
244
+ every config that has not been measured, so the registry can describe the
245
+ machine's full option set without making any claim it has not verified.
246
+ - **`measured_on` gates reuse.** A footprint measured on `gpu0` (24 GiB, sm_86)
247
+ is not valid for another device class or a different engine build. A stale or
248
+ mismatched measurement is treated as unknown.
249
+ - **Host RAM gates placement too.** `host_rss_mib` is checked against host
250
+ availability alongside `resident_gpu_mib`; §4.1.1 is why.
251
+ - **Cross-check against `config/capacity-policy.json`.** `uap models validate`
252
+ fails if a registry entry contradicts the policy it should agree with:
253
+ `metricsMustMatch` (`kv`, `max_context`), the launch flags in
254
+ `execStartMustContain` (`-c 229376`, `-np 2`, `--vbr-vram 5120M`,
255
+ `--cache-ram 32768` for `gsq-rco-server`), and the declared headroom
256
+ (`gpuMinFreeMiB: 200` for `strata-server`). One source of truth per number;
257
+ the registry references the policy, it does not restate it.
258
+ - **`rails` is not the model's concurrency claim.** `config/model-profiles/qwen38.json`
259
+ says `concurrency: 1` because Strata exposes one rail, while
260
+ `config/llama-profiles/qwen38-27b-mtp.env` sets `LLAMA_PARALLEL=2`. The
261
+ registry states it per config, cross-checked against a live probe. A mismatch
262
+ is surfaced, never silently resolved — `src/utils/model-slots.ts:13` falls
263
+ back to `DEFAULT_SLOTS = 2` when the probe fails, which is **safe for
264
+ llama.cpp and unsafe for Strata**: handing two concurrent requests to a
265
+ 1-rail server is exactly the overflow the profile warns about. Rail discovery
266
+ must become backend-aware before the registry can be trusted to allocate.
267
+
268
+ **Ownership — the registry must not become a fifth source of truth.** Endpoints
269
+ already live in four places: `src/models/types.ts:229` pins the live-backend
270
+ alias with a comment explaining why pinning more would "turn a backend switch
271
+ into an outage"; `config/model-profiles/`; `config/llama-profiles/`; and
272
+ `config/capacity-policy.json`. The registry therefore **references** rather
273
+ than restates, and `models validate` fails on a contradiction:
274
+
275
+ | Number | Owner | Registry holds |
276
+ | --- | --- | --- |
277
+ | Endpoint the proxy forwards to | proxy env (`LLAMA_CPP_BASE`) | read-only echo for display, never consumed by routing |
278
+ | Liveness + budget + metrics identity | `config/capacity-policy.json` (`services` is a **list** keyed by `name` — lookups must not assume a map) | the service `name` it cross-checks against |
279
+ | Launch flags per llama profile | `config/llama-profiles/*.env` | the profile path |
280
+ | Advertised model ids + session caps | `config/model-profiles/*.json` | cross-check only |
281
+ | Measured footprints (`resident_gpu_mib`, `host_rss_mib`, KV geometry) | `~/.uap/model-registry.json` | the only owner |
282
+ | Device totals + `reserved_mib` | `~/.uap/model-registry.json` | the only owner |
283
+
284
+ #### 4.1.1 What measurement actually returns (measured on this host)
285
+
286
+ The naive recipe — file size for weights, `nvidia-smi` used-minus-weights for
287
+ overhead — is wrong on both counts, and the numbers below are why.
288
+
289
+ Current live config: `strata-iq3_s.json`, engine `strata`, pack `iq3_s`,
290
+ `--max-context 131072 --kv int8 --kv-resident 32768 --vram-reserve-mib 1200
291
+ --spec 8 --mtp rt --expert-cache auto`.
292
+
293
+ | Quantity | Measured | Source |
294
+ | --- | ---: | --- |
295
+ | GPU total | 24,576 MiB | `nvidia-smi` |
296
+ | GPU used, engine pid 246788 | 21,812 MiB | `nvidia-smi compute-apps` |
297
+ | GPU used, non-backend processes | 290–1,516 MiB, varies by sample | same |
298
+ | GPU free | 245–975 MiB, drifting | `nvidia-smi`, `/metrics` |
299
+ | Engine host RSS | 52,857 MiB | `ps -o rss` |
300
+ | Server (python) RSS | 247 MiB | `ps -o rss` |
301
+ | Host RAM used / total | 92 GiB / 121 GiB | `/metrics` |
302
+ | GGUF on disk (native + PLE) | 52 GiB + 27 GiB | `ls -lh` |
303
+ | MTP draft on disk | 1.1 GiB | `ls -lh` |
304
+ | `expert_slots` | 7,736 | `/metrics` |
305
+ | `expert_cache_mib` | 15,017 MiB | `/metrics` |
306
+ | `arena_mib` | 47,962 MiB | `/metrics` |
307
+ | `vram_free_mib` | 917 MiB | `/metrics` |
308
+
309
+ Four conclusions the registry schema has to absorb:
310
+
311
+ 1. **Disk size is not a proxy for anything.** The GGUFs total 79 GiB; the GPU
312
+ footprint is 21.8 GiB. Expert weights are streamed from disk into a
313
+ 15 GiB VRAM cache sized by what is left over (`--expert-cache auto`). So no
314
+ pinned-weights figure can come from `ls`, and none can come from the engine
315
+ either: the pinned/cache split is not derivable from the fields Strata
316
+ reports. The registry therefore stores the whole-config **resident** figure
317
+ and nothing else.
318
+ 2. **The engine preallocates at startup.** `--vram-reserve-mib 1200` and
319
+ `--expert-cache auto` mean the process claims the card at load time. Free
320
+ VRAM after a load is a leftover, not a budget — which is why "free + what
321
+ the victim frees" is the only meaningful headroom question, and why
322
+ `load_alongside` is effectively unavailable on this host right now.
323
+ 3. **Host RAM is a first-class constraint, not a footnote.** The engine holds
324
+ 52.8 GiB of RSS (mmap'd GGUFs) against 121 GiB total with 92 GiB used. A
325
+ placement decision that fits VRAM and blows the page cache is a bad
326
+ decision. `host_rss_mib` therefore gates placement alongside
327
+ `resident_gpu_mib`: a candidate is admissible on a device only if that
328
+ device's measured host free RAM covers the candidate's RSS. (At measure time
329
+ the model is already resident, so the check belongs to admission, not
330
+ measurement — `measure` records the footprint and the host state it saw.)
331
+ 4. **The desktop is a permanent tenant, and it is bigger than the floor
332
+ assumes.** Non-backend GPU use measured 290–1,516 MiB across samples: one
333
+ sample showed Xorg 1,227 + Chrome 214 + Steam 75 = 1,516 MiB with 975 MiB
334
+ free; another showed only Warp 206 + nautilus 52 + the Logi overlay 24 +
335
+ snapd-desktop-integration 8 = 290 MiB, with `memory.used` at 23,105 MiB
336
+ against 21,812 + 290 = 22,102 accounted, leaving ~1,003 MiB attributed to no
337
+ PID at all. So `--query-compute-apps` is not a reliable census of GPU
338
+ consumers — Xorg appears in it sometimes and not others. The robust
339
+ quantity is the *unattributed remainder*, and `uap models measure` should
340
+ derive `reserved_mib` as `device.capacity_mib − free − Σ(compute apps that
341
+ are UAP backends)`, which captures graphics memory whether or not it is
342
+ attributed to a PID. Call it 1–1.5 GiB, about 5% of the card, permanently
343
+ unavailable to any backend. `config/capacity-policy.json` sets
344
+ `gpuMinFreeMiB: 200` for `strata-server`, which is why the doctor reports
345
+ GREEN at 245 MiB free: the policy's floor is smaller than the tenant it is
346
+ meant to protect. The placement layer must not inherit that number; it needs
347
+ its own measured per-device `reserved_mib`.
348
+
349
+ So `uap models measure` records, per *config*: `resident_gpu_mib` and
350
+ `host_rss_mib` from the live process, `context_pool_cells` /
351
+ `kv_resident_cells` / KV dtype from `/metrics`, and `expert_cache_mib` +
352
+ `arena_mib` where the engine reports them. Those last two are diagnostic only:
353
+ the admission cost uses the whole-config resident figure, because splitting an
354
+ elastic expert cache into "pinned" and "streamed" halves does not make the
355
+ decision any safer. Where an engine reports no cache, no split is invented.
356
+
357
+ `measure` refuses to write a footprint when the reading is not trustworthy:
358
+ engine PID not found, `resident_gpu_mib` below a sanity floor, a compute-apps
359
+ reading that includes another backend's allocation, or a device whose derived
360
+ `reserved_mib` exceeds its own capacity. It records `measured_on`,
361
+ `measured_at`, and the host free RAM it saw alongside every number, and
362
+ `validate` fails an entry whose measurement predates the unit's current
363
+ `ExecStart`.
364
+
365
+ ### 4.2 Placement ledger — `~/.uap/placement.json`
366
+
367
+ Live state. Written under an advisory lock; every writer re-reads before
368
+ committing so a stale view cannot clobber.
369
+
370
+ ```json
371
+ {
372
+ "version": 1,
373
+ "updated_at": "2026-10-08T14:02:11Z",
374
+ "devices": {
375
+ "gpu0": { "kind": "gpu", "total_mib": 24576, "free_mib": 1016,
376
+ "reserved_mib": 1293, "source": "nvidia-smi" },
377
+ "gpu1": { "kind": "gpu", "total_mib": 0, "free_mib": 0,
378
+ "headroom_mib": 0, "source": "absent" },
379
+ "cpu0": { "kind": "cpu", "total_mib": 126944, "free_mib": 30424,
380
+ "headroom_mib": 8192, "source": "MemAvailable" }
381
+ },
382
+ "residents": [
383
+ { "model": "qwen3.8-flash-next-iq3_s", "config": "strata-iq3_s",
384
+ "device": "gpu0", "engine": "strata",
385
+ "endpoint": "http://127.0.0.1:8080", "unit": "uap-strata-server",
386
+ "state": "hot", "gpu_mib": 21812, "host_rss_mib": 52857,
387
+ "holders": [], "since": "2026-10-08T09:14:02Z" }
388
+ ],
389
+ "pending": [
390
+ { "id": "plc-7f2a", "requested_model": "qwen3.8-27b", "client": "claude-code",
391
+ "session": "sess-…", "pid": 41122, "created_at": "…", "expires_at": "…",
392
+ "options": [ … ], "reason": "not_resident" }
393
+ ]
394
+ }
395
+ ```
396
+
397
+ `free_mib` for `gpu0` is what `nvidia-smi` reports right now (1,016 MiB at the
398
+ time of writing; observed range 245–1,016 as the engine's expert cache grows
399
+ and shrinks), not what would be free after a victim is drained — see §4.4.
400
+
401
+ Host RAM is a placement constraint in its own right, which is why `cpu0` is a
402
+ device and not a footnote. `free -m` on this box: 123.8 GiB total, 89.1 GiB
403
+ used, 29.7 GiB available. The Strata engine's RSS is 52.9 GiB (VmPeak 54.4
404
+ GiB) because mmap'd GGUF pages count toward RSS, and the expert cache is
405
+ 15,017 MiB of it. Two consequences:
406
+
407
+ - A CPU-offload candidate costs host RAM, so `cpu0.free_mib` is the budget and
408
+ `headroom_mib` must stay large enough for the proxy, the dashboard, and page
409
+ cache. Loading a second large model host-side would push this machine into
410
+ swap, which is a worse failure than refusing the request.
411
+ - `load_alongside` must check **both** devices. A candidate that fits the GPU
412
+ but not host RAM is infeasible, and the reason string must name the device
413
+ that failed rather than reporting a generic "insufficient memory".
414
+
415
+ Resident states: `hot` (serving), `warming` (loading, not yet accepting),
416
+ `paused` (offloaded, reloadable from disk — see below), `draining` (marked
417
+ for removal, refusing new requests, finishing in-flight).
418
+
419
+ `free_mib` is refreshed on read from `probeGpuFreeMiB()` — never trusted from
420
+ the last writer's snapshot. The ledger records intent; the probe records fact.
421
+ When they disagree, the probe wins and the disagreement is surfaced.
422
+
423
+ #### What `paused` is, and what it is not
424
+
425
+ Per Decision 4 in §9, `paused` means **the config's weights are offloaded and
426
+ will be reloaded from disk on resume**. It is not the proxy's existing
427
+ save/restore plumbing (`PROXY_SLOT_SAVE_RESTORE`,
428
+ `/slots/{id}?action=save|restore`), which is the right mechanism for a
429
+ different problem and the wrong answer here:
430
+
431
+ - A saved slot is a **host-RAM** artifact. It frees no VRAM, so pausing that
432
+ way cannot make room for a model that does not fit.
433
+ - The save path is single-threaded and slow: 10–20 s per slot at 128k context.
434
+ A pause that takes longer than a model load is not a pause.
435
+ - With 29.3 GiB of host RAM free against a 52.9 GiB resident process, there
436
+ may be no headroom for a checkpoint at all.
437
+
438
+ So a paused entry records the config's offload location and is resumable by
439
+ reloading from disk. No engine on this host has that reload path today: Strata
440
+ claims the card at startup (`--expert-cache auto`, `--vram-reserve-mib 1200`)
441
+ and has no in-process unload. Until one exists, `paused` is a **display state
442
+ the ledger can hold but the executor cannot produce**, and the only executable
443
+ option in the preview is `kill`. The preview must label which one it is
444
+ offering, because "pause" sounds free and currently is not.
445
+
446
+ ### 4.3 Admission gate — in the proxy
447
+
448
+ The gate runs **before** the wire model is rewritten and **before** anything is
449
+ sent upstream, and it decides on **the model the client asked for** — the id
450
+ that needs a placement decision. (An earlier draft gated after
451
+ `_reconcile_wire_model()` on its rewritten value; that ordering defeats the
452
+ gate: a non-resident id is silently rewritten to `ids[0]` before the gate ever
453
+ sees it, and the gate forwards exactly what it exists to park. Implemented the
454
+ other way, and in `mode=ask|auto` `_reconcile_wire_model()` skips its silent
455
+ rewrite entirely — an id the upstream does not serve is the operator's decision,
456
+ never an automatic substitution. `mode=off` keeps the rewrite, byte-identical.)
457
+
458
+ But **it is not unbypassable, and an earlier draft claimed it was.**
459
+ `_reconcile_wire_model` has exactly one call site, on `/v1/messages`. The proxy
460
+ also sends upstream from `/anthropic/v1/messages` (`:15743`),
461
+ `/v1/chat/completions` (`:15832`), `/api/chat` (`:16618`), and `/api/generate`
462
+ (`:16655`), plus the passthrough client (`_pt_client`, `:14473`) and the
463
+ save/restore client (`_sr_client`, `:13823`). The implemented gate is a
464
+ function of the requested model, and it is reached because every non-exempt
465
+ route funnels through the `/v1/messages` handler (the OpenAI and Ollama
466
+ surfaces run a synthetic request through `messages()` via
467
+ `_guarded_openai_completion`):
468
+
469
+ - `/v1/messages` — the gate, placed immediately before
470
+ `_reconcile_wire_model()`. Every named client (Claude Code, Codex, OpenCode,
471
+ UAP agents) enters here; this is the phase-2 deliverable.
472
+ - `/v1/chat/completions`, `/anthropic/v1/messages` — same handler; a park
473
+ surfaces in the OpenAI error dialect with the placement id and the
474
+ `uap models pending` resolve path.
475
+ - `/api/chat`, `/api/generate` (Ollama-compatible) — same handler; a park
476
+ surfaces in the Ollama error dialect the same way.
477
+ - Passthrough (`_pt_client`) — exempt by construction: it returns before the
478
+ gate, the model is cloud, it has no local cost, and it never reaches
479
+ `:8080`.
480
+
481
+ The §7 test list includes a test that each non-exempt route, in `mode=ask`,
482
+ parks rather than forwards when the requested local model is not resident.
483
+ "Gate the choke point" was the right instinct; "the choke point covers
484
+ everything" was a false statement about 17k lines and is now corrected.
485
+
486
+ Decision order:
487
+
488
+ | Condition | Action |
489
+ | --- | --- |
490
+ | Resolved model already resident and satisfies the request | forward |
491
+ | Cloud preset (no local cost) | forward, no placement decision |
492
+ | In registry, fits in free − reserve, `mode=auto` | controller auto-loads alongside, then forward (§4.4.1; displacing only for allowlisted models) |
493
+ | In registry, fits in free − reserve, `mode=ask` | park, ask operator |
494
+ | In registry, fits only by displacing | park, ask operator with impact preview |
495
+ | Not in registry | park, ask operator; **never auto-load** |
496
+ | `mode=off` | current behavior, byte-identical |
497
+
498
+ Parked requests return `409` with a machine-readable body (Anthropic error
499
+ shape so every gated client parses it with the code it already has; the
500
+ OpenAI/Ollama surfaces carry the same placement id and resolve path in their
501
+ own dialects), plus a `Retry-After: 2` header:
502
+
503
+ ```json
504
+ { "type": "error",
505
+ "error": { "type": "model_placement_pending",
506
+ "message": "model qwen3.8-27b is not loaded; resolve with `uap models pending` (placement plc-7f2a)",
507
+ "placement_id": "plc-7f2a",
508
+ "requested_model": "qwen3.8-27b",
509
+ "reason": "not_resident",
510
+ "retry_after_ms": 2000,
511
+ "resolve_with": "uap models pending | uap models apply <placement_id> <option> | dashboard Models tab" } }
512
+ ```
513
+
514
+ Contract notes (api-designer sign-off, 2026-10-08 — verdict: approve with
515
+ changes, all applied):
516
+
517
+ - `retry_after_ms` (and the header) are a re-poll **floor**, not an expected
518
+ resolution time — resolution is operator-bound; both dedupe windows make a
519
+ short re-poll cheap, and a resolved placement serves the next retry.
520
+ - `message` is the one field every Anthropic SDK surface renders to the
521
+ human; the operator instructions live there. The OpenAI/Ollama dialects
522
+ prefer this message over re-derived copy.
523
+ - `resolve_with` is human-renderable text, display-only, **no stability
524
+ guarantee** — machine consumers key on `type`, `placement_id`, `reason`
525
+ only.
526
+ - `reason` is machine-computed, never guessed: `unknown_model`,
527
+ `no_measured_config` (fail closed), `not_resident` (loadable, not loaded),
528
+ `auto_loading` (phase 4: the controller is loading the model itself — the
529
+ client's retry IS the protocol, no operator step exists, so the `message`
530
+ deliberately says so instead of pointing at `uap models pending`),
531
+ `auto_failed` (phase 4: the auto attempt failed; the pending entry survives
532
+ marked with this reason and the operator resolves it like any other park),
533
+ `unroutable_target` (the controller vouched for a resident on another
534
+ endpoint; this proxy posts to one discovered upstream, so the id is not
535
+ sent to it raw), `no_controller` (read-only admission with no reachable
536
+ controller — the resolution is "start the dashboard / set
537
+ PROXY_PLACEMENT_CONTROLLER", not a placement decision), or
538
+ `invalid_request` (controller-side: missing model_id). New values may be
539
+ added additively.
540
+ - `placement_id` is 24 bits within a 120s dedupe window: ~3e-6 collision at
541
+ ~10 concurrent pendings. Widen only if pending volume exceeds ~100 per
542
+ window.
543
+
544
+ `409` rather than a hang: the client keeps control, the proxy holds no socket
545
+ open across an unbounded operator decision, and a client that understands the
546
+ code can fall back on its own. `PROXY_PLACEMENT_HOLD_SECS` optionally holds the
547
+ connection for a bounded window first (capped 30s), for clients too dumb to
548
+ retry.
549
+
550
+ Pending requests dedupe on `(client, requested_model)`: ten concurrent Claude
551
+ Code requests for the same unloaded model produce one operator prompt, not ten.
552
+ Two windows, deliberately different: the proxy's own dedupe map is a **short
553
+ burst window** (~5s, enough to coalesce a burst that arrives in milliseconds)
554
+ so a resolved placement forwards on the very next request, while the
555
+ controller's ledger pending entry dedupes for **120s** — the operator-prompt
556
+ window. The controller re-checks the ledger and answers `forward` the moment a
557
+ live resident serves the id, so the long window never outlives a resolution.
558
+
559
+ Admission is two-tier. With `PROXY_PLACEMENT_CONTROLLER` set (emitted by
560
+ `uap setup` as the co-located dashboard, loopback), the proxy asks it: the
561
+ controller owns the registry + ledger view, answers `forward` with the
562
+ resident's target id (which keys the proxy's per-target concurrency budget),
563
+ or answers `park` and writes the pending entry. Without a controller, or when
564
+ it does not answer within its short timeout, the proxy degrades to read-only
565
+ admission from its own cached upstream ids: an id the upstream serves
566
+ forwards; an id it does not serve parks (`not_resident`); no knowledge
567
+ (unprobed cache) forwards — the same fail-open direction
568
+ `_reconcile_wire_model` takes with the same cache.
569
+
570
+ Environment: `PROXY_PLACEMENT_MODE=off|ask|auto` (default `off`;
571
+ `auto` is accepted and treated as `ask` until phase-3 enforcement exists),
572
+ `PROXY_PLACEMENT_CONTROLLER` (default empty → read-only admission),
573
+ `PROXY_PLACEMENT_HOLD_SECS` (default `0`).
574
+
575
+ #### The proxy cannot load a model today
576
+
577
+ This is the largest piece of new machinery and the spec should not imply
578
+ otherwise. `anthropic_proxy.py` has no subprocess spawn, no systemd call, and
579
+ no model lifecycle anywhere in its 17,000 lines. It forwards, and it discovers
580
+ what is already listening (`_discover_local_llama_base()` via `ss -ltnp`).
581
+ Model switching is a shell script an operator runs by hand.
582
+
583
+ So enforcement is a new module, `src/placement/enforce.ts`, invoked by the
584
+ proxy over a local control socket (or by the CLI/dashboard directly), which:
585
+
586
+ 1. drains victims (§4.5),
587
+ 2. `systemctl --user stop <victim unit>`,
588
+ 3. polls `nvidia-smi` until the expected MiB are actually released — with a
589
+ timeout, because a process that lingers holds the card,
590
+ 4. `systemctl --user start <new unit>` with the profile's flags,
591
+ 5. polls `/health`, then `/slots`, then `/metrics`, applying the capacity
592
+ policy's `metricsMustMatch` so a server that came up with the wrong KV
593
+ quant or context is caught rather than served,
594
+ 6. writes the ledger, releases the pending request,
595
+ 7. on any failure, rolls back: restart the victim unit, mark the request
596
+ failed, and let parked clients fall back.
597
+
598
+ Steps 2–5 are already what `~/.config/uap/model-switch.sh` does by hand,
599
+ including the `Conflicts=` unit graph. The script is the reference
600
+ implementation; the module is that script with drain, verification, and
601
+ rollback.
602
+
603
+ **One caveat the earlier draft glossed: those units are machine-local.**
604
+ `grep -rn "Conflicts=" config/` returns nothing — the mutually-conflicting
605
+ backend units live in `~/.config/systemd/user/`, installed by hand, and the
606
+ capacity policy itself records `strata-server` as unit-less. Enforcement
607
+ step 4 (`systemctl --user start <new unit>`) depends on units the repo does
608
+ not ship. Phase 2 therefore needs tracked unit templates with the `Conflicts=`
609
+ edges (extending `src/cli/systemd-services.ts`, which already installs
610
+ `uap-anthropic-proxy.service` and `uap-llama-server.service`), or the
611
+ registry must record that a unit's provenance is machine-local and
612
+ enforcement must refuse configs whose unit it cannot install. Degrading to
613
+ raw `systemd kill`/`ExecStart` strings is the failure the risk table already
614
+ forbids.
615
+
616
+ Two things that script does not do, and that the module must:
617
+
618
+ **Rollback after a failed start is the trust-critical step.** A failed start
619
+ that leaves the old unit stopped means the operator has no backend at all. The
620
+ module restarts the victim, captures its stderr into the event, and if the
621
+ rollback start also fails it stops trying and surfaces the observed state as a
622
+ red banner rather than looping. This path must be exercised in tests against a
623
+ simulated failed start before `auto` mode is available at all.
624
+
625
+ **The timeouts have an envelope nobody has measured.** The drain timeout, the
626
+ hold ceiling, and the release gate all need numbers, and those numbers depend on
627
+ how long this machine actually takes to tear a backend down and bring one up.
628
+ The only way to measure that is to perform a switch while watching, which takes
629
+ the live backend down and interrupts whatever is using it. So it is sequenced
630
+ deliberately: measure the envelope during one planned switch with the operator's
631
+ go-ahead, and until then the timeouts are conservative defaults labelled
632
+ unvalidated. Publishing a hold ceiling without a measured envelope behind it is
633
+ the same mistake as publishing a VRAM cost without a measurement.
634
+
635
+ **How the proxy finds the controller.** This is new wiring, not reuse — an
636
+ earlier draft claimed the lifecycle helper already passes `UAP_DASHBOARD_URL`
637
+ to the proxy; that variable exists nowhere in the repo. The proxy reads no
638
+ dashboard address today. So: `uap setup` emits `PROXY_PLACEMENT_CONTROLLER`
639
+ into `.uap/proxy.env` next to the other `PROXY_*` knobs it already emits, and
640
+ the unit file's `EnvironmentFile` picks it up. The value is a loopback URL.
641
+ Two constraints from the existing server code (`src/dashboard/server.ts`): the
642
+ dashboard may bind port 0 (OS-picked, resolved only after `listen`), and it may
643
+ bind `0.0.0.0` for LAN reachability — a placement control endpoint that stops
644
+ and starts systemd units must be **loopback-bound regardless of `--host`**,
645
+ with `mutationAuthorized(req)` on every write. If the controller URL is unset
646
+ or nothing answers it, admission runs read-only: it can answer "already
647
+ loaded" from the ledger, but it cannot load anything, so an unloaded request
648
+ gets `model_placement_pending` with reason `no_controller`, and
649
+ `uap models pending` tells the operator to start the dashboard or act from
650
+ the CLI.
651
+
652
+ #### Concurrency must become per-target
653
+
654
+ `upstream_semaphore` is one global `asyncio.Semaphore(PROXY_CONCURRENCY_LIMIT)`
655
+ (default 1) created at startup. With two models resident, one global limit is
656
+ wrong in both directions: a 2-slot llama.cpp server is throttled to 1, and a
657
+ 1-rail Strata server is allowed 2 and overflows.
658
+
659
+ Replace it with a per-target semaphore map keyed by the resolved wire model's
660
+ target, sized from the registry's `rails` and cross-checked against the live
661
+ `/slots` count — the same reconciliation `getModelSlotBudget()` already does
662
+ for the TypeScript side. The wire-model decision and the concurrency gate must
663
+ read the same target or they will disagree under load.
664
+
665
+ `model_backpressure` is likewise one row (`CHECK(id = 1)`), so exhaustion on
666
+ one endpoint currently suppresses every other endpoint. Key it by target id.
667
+ `model_leases` has no target column at all, so a lease taken against one
668
+ budget is consumed by a different model's request. Both changes are additive
669
+ schema migrations with a default target for existing rows.
670
+
671
+ ### 4.4 Options and cost math
672
+
673
+ Cost is per **config**, not per model, and it is measured rather than derived
674
+ from file sizes. Measured today:
675
+
676
+ | Config | Disk | GPU (measured) | Host RSS (measured) |
677
+ | --- | --- | --- | --- |
678
+ | `strata-iq3_s` | 79 GiB (52 native + 27 PLE) | **21,812 MiB** | **52,857 MiB** |
679
+ | `strata-ple` | ~52 GiB | not measured | not measured |
680
+ | `qwen38-27b-mtp` (llama.cpp) | 15 GiB | not measured | not measured |
681
+
682
+ Disk size is useless as a cost proxy: the active config puts 79 GiB on disk and
683
+ 21.8 GiB on the card, while the exl3 config puts 15 GiB on disk and needs more
684
+ than 22 GiB on the card. `nvidia-smi --query-compute-apps=pid,used_memory`
685
+ against the engine's own PID is the only number that means what the admission
686
+ gate needs, and `uap models measure` reads it from there.
687
+
688
+ The other half of the row is host RAM, and on this machine it is the binding
689
+ constraint. The Strata engine's resident set is 52.9 GiB of a 128 GiB host with
690
+ 30 GiB available — it keeps the 52 GiB native GGUF and the 27 GiB PLE file
691
+ mapped. A second config of that shape does not fit on the host even if the card
692
+ had room, so `fits_host` is not a formality and the registry must carry
693
+ `host_rss_mib` as a measured field with the same fail-closed rule as the GPU
694
+ number.
695
+
696
+ What the measured row corresponds to, from the live unit's `ExecStart`
697
+ (`config/strata-configs/strata-iq3_s.json`):
698
+
699
+ ```
700
+ --max-context 131072 --kv int8 --kv-resident 32768
701
+ --vram-reserve-mib 1200 --expert-cache auto --spec 8 --spec-min-p 0.8
702
+ ```
703
+
704
+ `/metrics` reports `expert_cache_mib: 15017` and `expert_slots: 7736` for that
705
+ config. So `kv_resident_cells` is 32768, not 131072: the first 32k cells of
706
+ context are already inside the 21,812 MiB, and marginal KV only starts accruing
707
+ past that. `--vram-reserve-mib 1200` is the engine's own internal floor and
708
+ belongs in `reserved_mib` for the device, not in the config's cost.
709
+
710
+ ```
711
+ cost_mib(config, cells) = resident_gpu_mib(config)
712
+ + max(0, cells − kv_resident_cells(config))
713
+ × kv_mib_per_1k_cells(config) / 1000
714
+
715
+ fits_gpu = cost_mib ≤ device.total_mib − Σ(resident_gpu_mib of everything
716
+ staying loaded) − device.reserved_mib
717
+ fits_host = host_rss_mib(config) ≤ cpu0.free_mib − cpu0.reserved_mib
718
+ fits = fits_gpu AND fits_host
719
+ ```
720
+
721
+ Three things that formula gets right and the naive version gets wrong:
722
+
723
+ **The device budget is a sum, not a free-memory reading.** The card is 24,576
724
+ MiB. The running engine holds 21,812 MiB and the desktop holds 1,516 MiB of
725
+ attributable compute plus roughly another 900 MiB of unattributed graphics
726
+ memory. The correct question is "does the set I intend to have loaded fit in
727
+ 24,576 minus the device's reserved floor minus nothing else", not "does
728
+ `nvidia-smi` say there is room".
729
+ `probeGpuFreeMiB()` reads `memory.free`, which on this machine reports 245–975
730
+ MiB at steady state — a number that is true and useless at the same time,
731
+ because it describes what is left after the operator's chosen configuration
732
+ already filled the card.
733
+
734
+ **`free_mib` must exclude the process being displaced.** Reading free memory
735
+ while the victim still holds the card reports the opposite of the truth: the
736
+ preview would promise room that does not exist until the victim is gone, and
737
+ the load would then OOM. Concretely, free is 975 MiB and the victim holds
738
+ 21,812 MiB, so a displacement that needs 22.5 GiB has 22,787 MiB to work with —
739
+ and only after the stop is verified, not before.
740
+
741
+ **Host RSS is a hard gate, not a footnote.** 52.8 GiB of the machine's 121.7
742
+ GiB is held by the running engine, with 29.7 GiB available. A placement that
743
+ fits the GPU and OOMs the host is still a failed placement. This is also what
744
+ makes `pause` expensive: save/restore moves KV to host RAM, and on this machine
745
+ there is not a lot of host RAM to move into.
746
+
747
+ `resident_gpu_mib` already contains whatever the engine preallocates, so no
748
+ separate overhead term is needed for the common case. For the live Strata
749
+ config, `/metrics` reports `expert_cache_mib: 15017` and `arena_mib: 47962`
750
+ inside the 21,812 MiB resident total; those are reported for diagnosis only and
751
+ never enter the decision. The marginal KV term matters only when a request asks
752
+ for more context than the measured pool (`--max-context 131072` with
753
+ `--kv-resident 32768` today); below that, the pool is already paid for.
754
+
755
+ `reserve_mib` is the operator's placement reserve, declared in the placement
756
+ config, and it is a different number from the capacity policy's
757
+ `gpuMinFreeMiB`. The policy's `200` for `strata-server` is a doctor floor
758
+ deliberately set low, because that service fills the card by design and the
759
+ measured steady state with the desktop sharing the GPU is ~245 MiB free; a
760
+ 600 MiB floor would hold the doctor permanently RED at the operator's chosen
761
+ operating point and train everyone to ignore it. The placement reserve is the
762
+ opposite question — how much to keep back so the *next* model can load — and
763
+ on a 24 GiB card it has to be stated explicitly or it is silently zero.
764
+
765
+ On today's machine the arithmetic is blunt: 24,576 total, 21,812 held by one
766
+ config, ~290 by the desktop, and every alternate config in the registry needs
767
+ 22–25 GiB. `load_alongside` has no solutions. Displacement is the only option,
768
+ and that is the correct output, not a bug — it is the same conclusion the
769
+ `Conflicts=` graph already encodes, now stated in MiB instead of unit names.
770
+
771
+ Generated options, in ranking order:
772
+
773
+ 1. **reuse** — a resident model already satisfies the request.
774
+ 2. **load_alongside** — fits on a device with room; nothing is displaced.
775
+ 3. **displace** — minimal eviction set that makes room, smallest first.
776
+
777
+ Ranking: reuse > load_alongside with matching `task_affinity` > load_alongside
778
+ > smallest displacement > largest displacement. The operator always sees the
779
+ full list; the ranking only sets default order and the recommended row.
780
+
781
+ #### 4.4.1 Auto-resolution policy (phase 4)
782
+ A parked request whose option list is non-empty does not have to wait for a
783
+ human `apply`. `src/placement/auto.ts` picks from the same ranked list the
784
+ operator sees, under two rules:
785
+
786
+ 1. **Non-displacing options auto-enforce when the policy is enabled** —
787
+ reuse needs nothing, load-alongside evicts nothing; the budget math
788
+ already said it fits.
789
+ 2. **Displacement auto-enforces only for allowlisted models.** The
790
+ allowlist (`uap models auto --allow-displace <model> --yes`) is the
791
+ standing operator confirmation the core invariant requires — see the
792
+ §6 phase-4 note for why this extends, not violates, "never displacement".
793
+
794
+ The policy lives in `~/.uap/placement-auto.json` (`UAP_PLACEMENT_AUTO`):
795
+ `{"enabled": bool, "allow_displace": [model keys]}` plus the §4.4.2 knobs.
796
+ **Opt-in, fail-closed**:
797
+ a missing file, a corrupt file, or a missing `enabled: true` key all mean
798
+ auto is OFF — every park waits for a manual apply, exactly as in phase 3.
799
+ `uap models auto` with no flags prints status; `--enable`/`--disable` flip
800
+ it; `--allow-displace` refuses to record anything without `--yes`, because
801
+ what it records is consent to evict unattended.
802
+
803
+ Mechanics, all fail-closed (hardened after the phase-4 parallel review):
804
+
805
+ - **One background run per requested MODEL** (`scheduleAutoResolution` keys
806
+ its run map by model, not placement id): the client-supplied identity
807
+ headers can mint fresh placement ids at request rate, so the id is no
808
+ dedupe at all — the model is the real "same load" key. The proxy parks
809
+ `auto_loading` reasons under a model-only burst key too, so rotating the
810
+ client-id header does not even re-ask the controller.
811
+ - **Single-flight machine-wide**: auto runs queue on one tail promise —
812
+ two alongside options computed against the same pre-load free-memory
813
+ snapshot can otherwise both start and oversubscribe the card. A hard cap
814
+ (`AUTO_MAX_CONCURRENT_RUNS = 2`) bounds the queue outright.
815
+ - **The entry outlives its run**: scheduling extends the pending entry's
816
+ `expires_at` past the enforcement envelope (drain + stops + verify-free +
817
+ start can exceed the 120s operator-prompt TTL), because a mid-run expiry
818
+ would mint a fresh entry and a second concurrent run for the same model.
819
+ - **A failed run parks its model in a cooldown** (~10 min): a persistently
820
+ failing load would otherwise re-enforce at every TTL expiry — an
821
+ unattended drain→stop→fail→rollback oscillation the operator consented
822
+ to neither. A later success clears the cooldown.
823
+ - **The run never rejects**: every ledger write inside it is caught — a
824
+ lock hiccup in a background run must not become an unhandled rejection
825
+ that kills the dashboard daemon. The entry self-heals via TTL expiry.
826
+ - Enforcement starts on a `setImmediate` yield, after the park answer is
827
+ built: its synchronous prefix (device probes, `systemctl is-active`, the
828
+ ledger lock) must not stall the admit path, which the proxy holds to a
829
+ 2s timeout.
830
+ - Success removes the pending entry; the client's next retry forwards.
831
+ Failure (or a thrown error) marks the entry `reason: "auto_failed"` —
832
+ one attempt per entry, plus the per-model cooldown above; the operator
833
+ sees it in `uap models pending` like any other park.
834
+ - Only a persisted, won-the-write-race entry schedules (the answer's
835
+ placement id must be the ledger's entry id, or a resolved pending would
836
+ have nothing to remove). A lost race or an unpersisted answer reports
837
+ the honest reason — never `auto_loading` for a run that will not
838
+ execute.
839
+ - Unknown and unmeasured models never auto-load — there is nothing to
840
+ enforce. The park reasons `unknown_model` / `no_measured_config` stand.
841
+ - If the controller dies mid-enforcement, the machine can be left with the
842
+ victim stopped and nothing loaded — the ledger self-heals (TTL, then
843
+ resync), the machine does not; recovery is manual (`uap models load`).
844
+ A durable enforcement queue is deliberately out of scope for one
845
+ operator's loopback dashboard; revisit only if controller death
846
+ mid-load is ever observed.
847
+
848
+ Precedence note: the CONTROLLER's auto policy (this section) decides
849
+ auto-loads; the proxy's `PROXY_PLACEMENT_MODE` never reaches the
850
+ controller. An operator running `mode=ask` with the auto policy enabled
851
+ gets auto-loads — the policy file is the single authority, and
852
+ `uap models auto --disable` is the off switch.
853
+
854
+ #### 4.4.2 Idle-resident unload (the missing reverse)
855
+
856
+ §4.4.1 closes the loop on the *load* side only: a parked request loads its
857
+ model, but nothing ever unloads a resident again — the first auto-loaded
858
+ model holds its device until a human remembers to stop it. The idle sweep
859
+ (`src/placement/idle.ts`, wired into the dashboard process that already owns
860
+ enforcement) is the consent-gated reverse:
861
+
862
+ - **Off by default.** No `unload_idle_after_secs` in the policy → the sweep
863
+ is a per-tick no-op. The knob floors at 60 s — under a minute the sweep
864
+ would oscillate (unload, next request auto-loads, unload again).
865
+ - **Consent per model.** `unload_allow` is the standing-consent twin of
866
+ `allow_displace` (`uap models auto --allow-unload <model> --yes`; the
867
+ dashboard's `/api/placement/auto` takes `allow_unload` with the same
868
+ `yes: true` gate). A resident not on the list is never touched.
869
+ - **A gated request served resets the clock.** The admit forward path
870
+ records `usage[model] = now` in the ledger (top-level, so it survives
871
+ the resident rebuild in `syncLedger`). The touch is fail-soft with a
872
+ short lock fuse (250 ms): a ledger hiccup or lock contention loses the
873
+ touch, which only delays an unload — never blocks a forward.
874
+ - **Arming never evicts the already-idle.** A resident with no usage record
875
+ gets its clock SEEDED at first observation; the sweep acts only on
876
+ windows it actually watched. A successful unload CLEARS the entry, so a
877
+ RELOADED model gets a fresh window instead of inheriting a stale clock
878
+ that predates it — and entries for models with no live resident are
879
+ pruned each tick, so residue cannot accumulate.
880
+ - **The sweep never races placement's own work.** A model with a
881
+ non-expired pending entry (parked for the operator, or mid-auto-load)
882
+ is skipped — demand exists. A refused or faulting unload parks the
883
+ model in a 10-minute failure cooldown, the twin of auto-load's: a
884
+ persistently failing stop would otherwise drain→stop→restart the
885
+ backend every 60 s, unsupervised. Overlapping ticks are dropped (a
886
+ 60 s drain window can exceed the 60 s cadence).
887
+ - **Unknown means busy.** The usage clock only sees gated traffic, so the
888
+ sweep also probes the endpoint for ESTABLISHED connections (`ss`, scoped
889
+ to the backend's server-side sockets): a client past the gate (direct
890
+ curl, another agent on the port) resets the clock and skips the tick.
891
+ If `ss` cannot answer, the resident is skipped — a refusal to guess,
892
+ never a guess that it's idle.
893
+ - **The stop is the operator's stop.** The sweep only decides WHEN; the
894
+ unload itself is `unloadPlacement` — drain, in-flight refusal (a
895
+ generation running at the final check aborts the stop), verify-free,
896
+ rollback. Fleet-spawned client dashboards set `UAP_IDLE_SWEEP=0`
897
+ (`false`/`off` also accepted), so exactly one sweeper touches the
898
+ shared ledger. Two OPERATOR-started dashboards would both sweep — same
899
+ pre-existing exposure as two manual unloads; run one.
900
+ - **Policy edits take effect without a restart**: the sweep re-reads the
901
+ policy file every tick.
902
+ - **Accepted loop surface (documented, not mitigated):** an unauthenticated
903
+ loopback caller can keep a resident pinned forever by re-admitting
904
+ (resetting its clock), or — with both auto-load and idle-unload armed —
905
+ drive park→load→idle→unload churn. The same caller could just use the
906
+ model; the allowlists still bound WHICH models are affected.
907
+
908
+ The dashboard's `/api/placement/auto` preserves these fields across every
909
+ save (spread, not rebuild), so an unrelated `--enable` cannot silently wipe
910
+ the unload consent.
911
+
912
+ ### 4.5 Impact preview
913
+
914
+ Every `displace` option carries the victim list. This is the answer to "what
915
+ will be killed or paused", and it is computed **before** the operator commits.
916
+
917
+ | Victim state | In flight | Consequence |
918
+ | --- | --- | --- |
919
+ | `hot` | yes | active generation aborted; context lost |
920
+ | `hot` | no | session idle, context lost, reload needed |
921
+ | `warming` | — | load wasted; VRAM returns on stop |
922
+ | `paused` | — | already offloaded; restore path lost |
923
+
924
+ Per victim: model, state, device, holders (client, pid, job, turns, context
925
+ cells), in-flight flag, cost it frees, and estimated reload cost for the
926
+ displaced model.
927
+
928
+ Two sources exist and neither alone answers "who is using this model":
929
+
930
+ - `model_leases` (`src/coordination/database.ts`) has a `holder` column, but
931
+ `activeModelLeases()` (`src/coordination/service.ts:1061`) returns only a
932
+ count, and the holder is a lease-holder string (`model:<apiModel>`,
933
+ `agentic:<apiModel>`), not a client identity.
934
+ - The proxy holds the live request state, but it is **not exposed**. An
935
+ earlier draft named a `_client_registry` served at `/v1/api/clients` —
936
+ neither exists. The real primitives are `_current_request_session`
937
+ (contextvar, `:4853`), the in-flight counters hung on the httpx client
938
+ (`_inflight_inc`/`_inflight_dec`, `:4628-4637`), session admission state
939
+ (`_admitted_sessions`, `:4920`), and client identity
940
+ (`resolve_client_id` + `_client_request_times`, `:2011-2053`). None has a
941
+ route.
942
+
943
+ So the preview needs a **new proxy endpoint** — in-flight work per target
944
+ (client id, session, request id, started-at) — as an explicit phase-2
945
+ deliverable, not a join of two existing registries. **Shipped** (phase 3) as
946
+ the loopback-only `GET /internal/placement/inflight`, keyed by the same
947
+ target id the admission gate sets (`device:endpoint`, `default` when no
948
+ controller answer set one). The registration lives at the httpx client's
949
+ `send()` override — the one choke point every upstream call shares — not at
950
+ `_post_with_retry` alone: guardrail and recipe passes post directly, and the
951
+ first cut (guarded only `_post_with_retry`) was caught by a live smoke showing
952
+ an empty list mid-generation. The `uap models apply` impact list reads this
953
+ endpoint (best-effort; `unknown` when the proxy does not answer — never a
954
+ guessed "idle"). The lease side still needs a target column (§4.3) before it
955
+ can attribute a holder to a specific resident model.
956
+
957
+ Nothing in this view is advisory-only: the enforcement step re-derives the
958
+ same list and **refuses to proceed if it differs** from what the operator
959
+ approved. A preview that lies is worse than no preview.
960
+
961
+ ### 4.6 Enforcement
962
+
963
+ Only after an explicit operator yes. The sequence is §4.3's module, with the
964
+ failure modes the repo has already recorded:
965
+
966
+ 1. Mark victims `draining`; refuse new requests to them.
967
+ 2. Wait `drain_ms` for in-flight work to finish. Do not cut it off silently.
968
+ 3. Stop the victim unit (`systemctl --user stop`).
969
+ 4. **Verify the VRAM actually came back.** `qwen38-27b-dflash2.env:50-56`
970
+ documents the failure mode: a server started outside systemd was observed
971
+ leaving an orphaned `llama-server` holding 21362 MiB with no listening
972
+ socket, starving the real service on load. Check
973
+ `nvidia-smi --query-compute-apps=pid,used_memory --format=csv` for an
974
+ orphan before concluding the load failed for capacity reasons.
975
+ 5. Start the new unit with the profile's flags.
976
+ 6. Poll `/health`, then the advertised ids (`/v1/models`): the load-bearing
977
+ check is that the requested model is actually SERVABLE now. **Shipped
978
+ scope (phase 3): id-level verify-up only** — the deep `/slots` +
979
+ `/metrics` `metricsMustMatch` check ("wrong `kv` kind or pool size is
980
+ RED, not up") stays `uap doctor`'s job (the existing capacity cross-check
981
+ runs the same probes with the policy attached); wiring it into the
982
+ enforcement sequence is an open follow-up, not a silent drop.
983
+ 7. **Invalidate the proxy's cached view of the upstream, then** update the
984
+ ledger; release the pending request; the client retries and is served.
985
+ `_upstream_model_ids_cached()` (`:1648`) caches the upstream's advertised
986
+ ids **for the process lifetime** ("fetched at most once per process"), and
987
+ `_upstream_model_name()` (`:1724`) reads the same cache. Skip this and the
988
+ first request after a successful switch fails the `requested in ids`
989
+ check and gets silently rewritten to the *previous* backend's first id —
990
+ no banner, wrong model. The invalidation must also cover the `/props` and
991
+ `/slots` snapshots, `_admitted_sessions` (`:4920`), and the parked-request
992
+ burst map. This is a step in the sequence and a row in the §7 test list,
993
+ not an implementation detail. Shipped as the loopback-only
994
+ `POST /internal/placement/refresh` endpoint; the pooled connections are a
995
+ documented deviation (one retried request post-swap rather than a pool
996
+ swap). Phase 3 automates this step inside the sequence (`uap models
997
+ apply`/`unload` fire it as `refresh-proxy`); a MANUAL switch run outside
998
+ the tooling still fires the same hook by hand:
999
+
1000
+ curl -s -X POST http://127.0.0.1:${PROXY_PORT:-4000}/internal/placement/refresh \
1001
+ -H "Authorization: Bearer ${PROXY_AUTH_TOKEN:-}"
1002
+ 8. Any step fails → roll back to the previous resident set and return a
1003
+ fallback to the client rather than leaving the machine with nothing loaded.
1004
+ A unit STARTED during the failed sequence is stopped first (two
1005
+ same-device residents is the same outcome step 8 exists to prevent), and
1006
+ the sequence's `draining` markers are cleared (the marker must survive
1007
+ concurrent ledger syncs MID-drain, but must not leak past the rollback).
1008
+
1009
+ Step 8 is the one that decides whether this feature is trustworthy. A partial
1010
+ enforcement leaves the machine with no model loaded and every agent pointed at
1011
+ `:4000`. The rollback must be exercised in tests against a simulated failed
1012
+ start, not only in the success path.
1013
+
1014
+ **The enforcement envelope is unmeasured, and it needs to be.** The pending-hold
1015
+ timeout and the drain timeout in step 3 are constants in every draft so far.
1016
+ They should come from a measured teardown/start envelope per config: how long
1017
+ `systemctl --user stop <unit>` takes, whether VRAM release can be confirmed by
1018
+ re-probing the engine PID rather than trusting unit state, and how long the
1019
+ alternate profile takes to reach `/health` warm versus cold. Measuring this
1020
+ means briefly taking the live backend down, so it needs the operator's explicit
1021
+ go-ahead; it is the first thing to do once they are ready to touch a running
1022
+ model.
1023
+
1024
+ ### 4.7 Surfaces
1025
+
1026
+ Namespace note: `uap models` (plural, placement) sits next to the existing
1027
+ `uap model` (singular, multi-model routing at `src/bin/cli.ts:2035`) — a
1028
+ near-homonym, kept because they answer different questions (where does it
1029
+ live vs which model answers). Help text for both cross-references the other.
1030
+
1031
+ **TUI** — `uap models`:
1032
+
1033
+ ```
1034
+ uap models status residents, devices, free/headroom, holders
1035
+ uap models pending parked requests with ranked options
1036
+ uap models apply <id> <option> explicit yes; prints the impact list first
1037
+ uap models dismiss <id> refuse; client gets a clean fallback
1038
+ uap models load <model> operator-initiated, same gate (no --device yet)
1039
+ uap models unload <model> operator-initiated, same preview
1040
+ uap models units write Conflicts= drop-ins for same-device units
1041
+ uap models measure <model> populate measured footprints
1042
+ uap models validate registry ↔ capacity-policy cross-check
1043
+ ```
1044
+
1045
+ **Dashboard** — replace the Models tab stub. `web/dash/tab-models.js` prints
1046
+ "Models — coming soon"; `web/dash/tabs.js:494` registers a `models` tab that
1047
+ renders cost and routing only. Neither shows placement. New routes on the
1048
+ existing server:
1049
+
1050
+ ```
1051
+ GET /api/placement/state GET /api/placement/pending GET /api/placement/preview
1052
+ POST /api/placement/resolve POST /api/placement/dismiss
1053
+ POST /api/placement/load POST /api/placement/unload
1054
+ ```
1055
+
1056
+ Reads are open, like other dashboard reads. Writes go through
1057
+ `mutationAuthorized(req)`, the same gate as `/api/policy/*/toggle`. Push uses
1058
+ the existing event stream; it needs a new category (`placement`) alongside
1059
+ `policy`, `memory`, `deploy`, `agent`, `task`, `skill`, `pattern`, `cost`,
1060
+ `system`.
1061
+
1062
+ Both surfaces call the same `src/placement/` functions. A decision made in the
1063
+ TUI appears in the dashboard on the next event, and vice versa.
1064
+
1065
+ ## 5. What this forces us to fix
1066
+
1067
+ Not optional extras — the design does not work without them.
1068
+
1069
+ 1. **Per-target leases and backpressure.** `model_backpressure` is one row, so
1070
+ two residents share one adaptive limit; a struggling model would throttle a
1071
+ healthy one. Key `model_leases` and `model_backpressure` by target id,
1072
+ keeping the single-transaction reap → count → insert that makes
1073
+ `acquireModelSlot()` safe today.
1074
+ 2. **Per-target upstream semaphores.** `upstream_semaphore` is one global gate
1075
+ sized from `PROXY_CONCURRENCY_LIMIT`. With two residents it lets traffic
1076
+ aimed at model A queue behind model B. The wire-model decision and the
1077
+ concurrency gate must not diverge — `qwen38-27b-mtp.env` already warns to
1078
+ keep `PROXY_CONCURRENCY_LIMIT` equal to the slot count and
1079
+ `PROXY_CONTEXT_WINDOW` equal to per-slot context.
1080
+ 3. **Backend-aware rail discovery.** `getModelSlotBudget()` falls back to
1081
+ `DEFAULT_SLOTS = 2` when a probe errors. On a single-rail backend that is a
1082
+ silent lie: it advertises two slots to a card that has one. A live server
1083
+ answering 404/405/501 at `/slots` is already treated as single-rail; an
1084
+ unreachable or unrecognized backend must not default to 2.
1085
+ 4. **Multi-GPU probing.** `probeGpuFreeMiB()` reads GPU 0 only. The
1086
+ multi-device abstraction needs per-UUID readings.
1087
+
1088
+ ### 5.1 The lease migration, concretely
1089
+
1090
+ Item 1 is the only change here that touches live state, so it gets its own
1091
+ sketch. Today (`src/coordination/service.ts:1050-1088`):
1092
+
1093
+ ```ts
1094
+ acquireModelSlot(holder: string, budget: number, ttlMs = 120_000): number | null {
1095
+ const txn = this.db.transaction((): number | null => {
1096
+ const nowIso = new Date(Date.now()).toISOString();
1097
+ // DELETE first so the transaction takes the write lock before counting.
1098
+ this.db.prepare(`DELETE FROM model_leases WHERE expires_at < ?`).run(nowIso);
1099
+ const c = (this.db.prepare(`SELECT COUNT(*) as c FROM model_leases`).get() as { c: number }).c;
1100
+ if (c >= Math.max(1, budget)) return null;
1101
+ const info = this.db
1102
+ .prepare(`INSERT INTO model_leases (holder, acquired_at, expires_at) VALUES (?, ?, ?)`)
1103
+ .run(holder, nowIso, new Date(Date.now() + ttlMs).toISOString());
1104
+ return Number(info.lastInsertRowid);
1105
+ });
1106
+ return txn();
1107
+ }
1108
+ ```
1109
+
1110
+ The safety property is the ordering: delete, then count, then insert, inside
1111
+ one transaction, so the write lock is held before the count is read. Any
1112
+ target-keyed version must preserve that ordering. The change is a `target`
1113
+ column and a `WHERE target = ?` on the count:
1114
+
1115
+ ```sql
1116
+ ALTER TABLE model_leases ADD COLUMN target TEXT NOT NULL DEFAULT 'default';
1117
+ CREATE INDEX IF NOT EXISTS idx_model_leases_target ON model_leases(target, expires_at);
1118
+ ```
1119
+
1120
+ `model_backpressure` drops its `CHECK(id = 1)` constraint in favour of a
1121
+ primary key on the target id. SQLite cannot drop a table constraint in place,
1122
+ so this is the standard create-new-table → copy → drop → rename, inside one
1123
+ transaction. Map the legacy `id = 1` row to `target = 'default'` in the copy so
1124
+ a revert of the code loses no operator-tuned state — that keeps the one-way
1125
+ door reversible in code only.
1126
+
1127
+ Three rules keep this from breaking anything:
1128
+
1129
+ - **`target = 'default'` is the legacy bucket.** Existing rows and existing
1130
+ callers that do not pass a target land there, so behaviour is unchanged
1131
+ until a caller opts in by naming a target. The migration is additive.
1132
+ - **`target` is the placement target identity (device + endpoint from the
1133
+ ledger), never the model name.** The existing holder strings already embed
1134
+ the model id (`model:<apiModel>`, `agentic:<apiModel>` — see
1135
+ `src/models/openai-compat-client.ts:260` and
1136
+ `src/delivery/agentic-executor.ts:2569`). Keying `target` on `apiModel`
1137
+ would make a backend switch change the key mid-flight: outstanding leases
1138
+ orphan, and per-target backpressure resets — the exact hazard
1139
+ `src/models/types.ts:229` documents for pinned endpoints. The ledger's
1140
+ target id is stable across model changes on the same endpoint.
1141
+ - **`acquireModelSlot` keeps its current signature** with `target` as an
1142
+ optional trailing argument. The only direct TS caller is
1143
+ `src/utils/model-slot-lease.ts:94` (an earlier draft also listed
1144
+ `src/cli/coord.ts` — it does not call it); the real consumers all go through
1145
+ `withModelSlot()` at `src/models/openai-compat-client.ts:260` and
1146
+ `src/delivery/agentic-executor.ts:2569`. None of them changes in the same
1147
+ commit that adds the column.
1148
+
1149
+ **The migration needs an idempotence guard this database does not have.**
1150
+ `src/coordination/database.ts` contains only `CREATE TABLE IF NOT EXISTS` — no
1151
+ `ALTER TABLE`, no `PRAGMA user_version`, no version marker at all, so an
1152
+ unguarded `ALTER` next to the creates throws on the second open. The precedent
1153
+ to follow is `src/memory/short-term/schema.ts:23` — probe
1154
+ `PRAGMA table_info(model_leases)`, add the column only if `target` is absent,
1155
+ and guard the `model_backpressure` rebuild on its actual column shape. The
1156
+ backpressure rebuild copies the same create-new-table → copy → drop → rename
1157
+ shape that file's comment describes verbatim ("SQLite doesn't support ALTER
1158
+ TABLE to change CHECK constraints, so we must rebuild the table").
1159
+
1160
+ ## 6. Phasing
1161
+
1162
+ | Phase | Ships | Proxy mode |
1163
+ | --- | --- | --- |
1164
+ | 0 | registry, `models validate`, `models measure` | `off` |
1165
+ | 1 | ledger, `models status`, `models pending`, cost math, preview | `off` |
1166
+ | 2 | proxy gate, `409` pending, dashboard state/pending, Models tab | `ask` |
1167
+ | 3 | enforcement: drain, stop, verify-free, start, verify-metrics, rollback; `apply`/`dismiss`/`load`/`unload`/`units` surfaces; `Conflicts=` drop-ins; the in-flight proxy endpoint | `ask` |
1168
+ | 4 | auto mode: the controller auto-loads a parked, placeable request — alongside always; by displacement only for models the operator allowlisted (`uap models auto --allow-displace <model> --yes`). Never displacement by default. | `auto` |
1169
+
1170
+ Phase 4's displacement allowlist extends the earlier "never displacement"
1171
+ wording deliberately, and the argument is the spec's own invariant: *never
1172
+ evict without explicit operator confirmation*. The allowlist IS that
1173
+ confirmation — standing, written deliberately under a `--yes` that prints
1174
+ what it consents to, auditable in `~/.uap/placement-auto.json`, and removable
1175
+ per model at any time. Displacement stays impossible without it; alongside
1176
+ auto-load is the only automatic path. (Recorded here because the phase table
1177
+ previously said "never displacement" flatly, and a reviewer should find the
1178
+ reasoning, not just the change. The decision record is
1179
+ `docs/architecture/adr/0007-auto-displacement-allowlist-as-standing-consent.md`.)
1180
+
1181
+ Phase 0 is useful on its own: it turns the prose in five profile files into
1182
+ data `uap doctor` can act on. Nothing gates on the rest.
1183
+
1184
+ Two ordering constraints, plus one refactor the review forced us to schedule:
1185
+
1186
+ - **Item 3 (backend-aware rail discovery) lands in phase 0, not later.** The
1187
+ registry's `rails` field is only trustworthy if the probe that fills it
1188
+ distinguishes "single rail" from "probe failed". Fixing the fallback after
1189
+ building on top of it means re-measuring every entry.
1190
+ - **Item 1 (target-keyed leases and backpressure) lands between phases 1 and
1191
+ 2.** The ledger can be built against today's single-row backpressure, but
1192
+ the proxy gate cannot: a gate that admits per-target while the lease budget
1193
+ and the upstream semaphore are global will admit more traffic than the
1194
+ resident model can serve. Phase 2 does not open until the lease table
1195
+ carries a target column and the semaphore map is keyed the same way.
1196
+ - **Phase 1.5: per-target upstream addressing in the proxy.** A per-target
1197
+ semaphore on top of a single upstream address is decorative: `LLAMA_CPP_BASE`
1198
+ is one module constant with 35 references, including every derived probe
1199
+ (`.replace("/v1", "/props")` at `:3403`/`:16452`, `/slots` at `:3470`/
1200
+ `:3529`/`:4926`/`:15073`, `/health` at `:5210`/`:16722`), CLOSE-WAIT
1201
+ accounting (`_upstream_port()`, `:4580`), and the streaming retry loops
1202
+ (`:15065-15700`). The enabler is a resolved target object
1203
+ `{target_id, base_url, slots_url, props_url, health_url}` threaded through
1204
+ the send paths, with `LLAMA_CPP_BASE` as the default target so single-backend
1205
+ behavior is unchanged. It is the riskiest refactor in a 17k-line file; it
1206
+ gets its own branch, its own review, and lands before the phase-2 gate —
1207
+ otherwise phases 3–4 admit per-target placement the proxy cannot address.
1208
+ - **Tracked unit templates are a phase-2 prerequisite** (see §4.3's caveat):
1209
+ enforcement can only `systemctl --user start` units that exist, and today
1210
+ the `Conflicts=` graph lives only in `~/.config/systemd/user/`.
1211
+
1212
+ Implementation runs in `.worktrees/345-model-placement/` on
1213
+ `feature/345-model-placement`, per the worktree gate.
1214
+
1215
+ ## 7. Tests
1216
+
1217
+ - Cost math: boundary at exactly `free − headroom`; unknown footprint fails
1218
+ closed; `kv_per_1k` scaling.
1219
+ - Preview: minimal eviction set selection; victim list matches enforcement
1220
+ (a mismatch must abort).
1221
+ - Registry: schema validation; disagreement with `capacity-policy.json` fails.
1222
+ - Ledger: concurrent writers, pending expiry, dead-PID holder pruning.
1223
+ - Proxy gate: `409` shape, dedupe per `(client, model)`, **`mode=off`
1224
+ produces byte-identical behavior to today** — the regression that matters
1225
+ most, because the proxy serves every client — and, per §4.3's route list,
1226
+ **each non-exempt entry route parks in `mode=ask`** when the requested
1227
+ local model is not resident (`/v1/chat/completions`, `/api/chat`,
1228
+ `/api/generate`, `/anthropic/v1/messages`), while passthrough stays exempt.
1229
+ - Enforcement: after a successful swap the proxy's upstream id cache is
1230
+ invalidated — the next request must not be rewritten to the previous
1231
+ backend's id (§4.6 step 7).
1232
+ - Dashboard: writes require `mutationAuthorized()`; reads do not.
1233
+ - Existing suites must stay green: `test/models/lease-heartbeat.test.ts`,
1234
+ `test/models/openai-compat-lease.test.ts`,
1235
+ `test/coordination/adaptive-backpressure.test.ts`,
1236
+ `test/coordination/model-slot-lease.test.ts`.
1237
+
1238
+ ## 8. Risks
1239
+
1240
+ | Risk | Mitigation |
1241
+ | --- | --- |
1242
+ | Footprint drift: measured cost stops matching reality | `models measure` re-measures; `doctor` compares resident actual vs declared and flags drift |
1243
+ | Orphaned VRAM after a failed stop | query compute-apps for orphans before diagnosing capacity; documented in the profile since 2026-08-20 |
1244
+ | Operator absent, requests park forever | `expires_at` on every pending request; expiry returns a clean fallback to the client |
1245
+ | Two controllers race on the ledger | advisory lock + re-read-before-commit; enforcement re-derives the victim set |
1246
+ | A monitor watchdog fights enforcement | the units already declare `Conflicts=`; enforcement uses `systemctl --user`, never raw signals |
1247
+ | Preview and enforcement disagree | enforcement aborts rather than acting on a stale preview |
1248
+ | A backend with no systemd unit cannot be displaced | the capacity policy probes `strata-server` over HTTP precisely because the repo ships no unit for it; enforcement treats "no unit" as a distinct state, never as zero cost, and phase 2 ships tracked unit templates (§4.3) |
1249
+ | Auto mode displaces something important | `auto_load.displace: false` is the default and the only supported value in phase 4 |
1250
+
1251
+ ## 9. Decisions taken
1252
+
1253
+ These were open in earlier drafts and are now settled.
1254
+
1255
+ 1. **Registry location: repo defaults merged with machine-local overrides.**
1256
+ `config/model-registry.json` holds the model names, engines, units, launch
1257
+ scripts, rails, and context geometry — everything reviewable and mostly
1258
+ machine-independent. `~/.uap/model-registry.json` holds the measured numbers
1259
+ for this machine: device totals, `reserved_mib`, and each config's
1260
+ `resident_gpu_mib` / `host_rss_mib`. The loader deep-merges the local file
1261
+ over the repo file per model and per device, and `uap models validate`
1262
+ reports which file each field in each entry came from, so a stale machine
1263
+ number is never mistaken for a reviewed default. A machine with no local
1264
+ file has no measured footprints, which means everything fails closed — the
1265
+ correct outcome for an unmeasured box.
1266
+ 2. **Enforcement lives on the TS side, invoked by the proxy over loopback
1267
+ HTTP.** The proxy is Python and has no systemd machinery; the TS side already
1268
+ owns the units, the capacity policy, the lease ledger, and the dashboard.
1269
+ The proxy calls a loopback control endpoint exposed by the dashboard server
1270
+ (`src/dashboard/server.ts` already runs an HTTP server with mutation
1271
+ authorization) rather than by a new daemon, so there is one process holding
1272
+ placement state and the TUI and dashboard read the same one. The drain
1273
+ therefore reaches the proxy's live request state through an endpoint, not
1274
+ in-process.
1275
+ 3. **Alternate configs stay unknown and fail closed.** Only the running config
1276
+ has a measured footprint. The other seven backends keep `cost: null` until
1277
+ the operator runs `uap models measure` against a live instance of each,
1278
+ which means they appear in the UI as "not measured" rather than as loadable
1279
+ options. Deliberately not measuring them now: measuring requires starting
1280
+ each one, which takes the live backend down.
1281
+ 4. **`paused` means offload and reload from disk later.** It is not the proxy's
1282
+ existing save/restore mechanism, which checkpoints one slot's KV to host RAM
1283
+ and keeps the slot — it frees no VRAM, is opt-in, gated on single-session
1284
+ use, and with 29.3 GiB of host RAM available against a 52.9 GiB resident
1285
+ process may not have headroom for a checkpoint at all. A paused config is
1286
+ recorded with its offload location and is resumable by reloading from disk.
1287
+ Until that reload path exists for a given engine, `paused` is display-only
1288
+ for that config and the only executable option is `kill`.
1289
+
1290
+ ## 10. Open questions
1291
+
1292
+ 1. **Video and image models.** They are the trigger the operator described but
1293
+ they are not LLMs and have a different cost shape. Do they get a separate
1294
+ placement lane in v1, or does the registry stay LLM-only?
1295
+ 2. **Whose consent for someone else's eviction.** The operator confirms. Does
1296
+ the client whose live generation is being aborted also get a say, or is the
1297
+ operator's yes sufficient? v1 assumes the latter.
1298
+ 3. **Cloud models as placement options.** They cost nothing locally, so they
1299
+ pass through. Should they appear in the ranked list as a fallback when
1300
+ nothing fits locally?
1301
+ 4. **The enforcement time envelope.** The drain timeout and the pending-hold
1302
+ timeout are constants in every draft so far. They need a measured teardown
1303
+ and start envelope per config: how long stopping each unit takes, whether
1304
+ VRAM release can be confirmed by re-probing the engine PID rather than
1305
+ trusting unit state, and how long the alternate profile takes to reach
1306
+ `/health` warm versus cold. Measuring it takes the live backend down, so it
1307
+ needs the operator's explicit go-ahead.
1308
+ 5. **Is host RSS a hard gate or a warning?** It is the binding constraint on
1309
+ this machine (92.4 GiB of 121.7 GiB used), but the number is dominated by
1310
+ mmap'd weights and page cache, so a naive threshold risks false alarms.
1311
+ 6. **How `paused` reload actually works per engine.** Strata's `--vram-elastic`
1312
+ and `POST /v1/vram` adjust the arena between requests, which is a different
1313
+ lever from offloading a whole config to disk and bringing it back. Which
1314
+ engines can do the latter at all, and what it costs, is unmeasured.