@plurnk/plurnk-service 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/.env.defaults +37 -31
  2. package/INSTALL.md +16 -11
  3. package/README.md +1 -0
  4. package/SPEC.md +631 -397
  5. package/dist/Paths.d.ts.map +1 -1
  6. package/dist/Paths.js +2 -3
  7. package/dist/Paths.js.map +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/content/edit-collision.d.ts +5 -0
  10. package/dist/content/edit-collision.d.ts.map +1 -0
  11. package/dist/content/edit-collision.js +14 -0
  12. package/dist/content/edit-collision.js.map +1 -0
  13. package/dist/content/edit-receipt.d.ts +3 -2
  14. package/dist/content/edit-receipt.d.ts.map +1 -1
  15. package/dist/content/edit-receipt.js +58 -13
  16. package/dist/content/edit-receipt.js.map +1 -1
  17. package/dist/content/index.d.ts +5 -1
  18. package/dist/content/index.d.ts.map +1 -1
  19. package/dist/content/index.js +4 -1
  20. package/dist/content/index.js.map +1 -1
  21. package/dist/content/line-anchors.d.ts +41 -0
  22. package/dist/content/line-anchors.d.ts.map +1 -0
  23. package/dist/content/line-anchors.js +191 -0
  24. package/dist/content/line-anchors.js.map +1 -0
  25. package/dist/content/line-marker.d.ts +7 -3
  26. package/dist/content/line-marker.d.ts.map +1 -1
  27. package/dist/content/line-marker.js +2 -1
  28. package/dist/content/line-marker.js.map +1 -1
  29. package/dist/content/matcher.d.ts +1 -1
  30. package/dist/content/matcher.d.ts.map +1 -1
  31. package/dist/content/matcher.js +2 -2
  32. package/dist/content/matcher.js.map +1 -1
  33. package/dist/content/read-projector.d.ts +20 -0
  34. package/dist/content/read-projector.d.ts.map +1 -0
  35. package/dist/content/read-projector.js +122 -0
  36. package/dist/content/read-projector.js.map +1 -0
  37. package/dist/content/read-resolve.d.ts +2 -7
  38. package/dist/content/read-resolve.d.ts.map +1 -1
  39. package/dist/content/read-resolve.js +32 -107
  40. package/dist/content/read-resolve.js.map +1 -1
  41. package/dist/core/BranchReceipt.d.ts +0 -1
  42. package/dist/core/BranchReceipt.d.ts.map +1 -1
  43. package/dist/core/BranchReceipt.js +0 -3
  44. package/dist/core/BranchReceipt.js.map +1 -1
  45. package/dist/core/BudgetReadout.d.ts.map +1 -1
  46. package/dist/core/BudgetReadout.js +9 -2
  47. package/dist/core/BudgetReadout.js.map +1 -1
  48. package/dist/core/ChannelWrite.d.ts +3 -0
  49. package/dist/core/ChannelWrite.d.ts.map +1 -1
  50. package/dist/core/ChannelWrite.js +3 -3
  51. package/dist/core/ChannelWrite.js.map +1 -1
  52. package/dist/core/ChannelWrite.sql +4 -3
  53. package/dist/core/CoreSchemeServices.d.ts +10 -2
  54. package/dist/core/CoreSchemeServices.d.ts.map +1 -1
  55. package/dist/core/CoreSchemeServices.js.map +1 -1
  56. package/dist/core/Dispatcher.d.ts +14 -8
  57. package/dist/core/Dispatcher.d.ts.map +1 -1
  58. package/dist/core/Dispatcher.js +656 -312
  59. package/dist/core/Dispatcher.js.map +1 -1
  60. package/dist/core/DurableStatement.d.ts.map +1 -1
  61. package/dist/core/DurableStatement.js +2 -0
  62. package/dist/core/DurableStatement.js.map +1 -1
  63. package/dist/core/Engine.d.ts +24 -18
  64. package/dist/core/Engine.d.ts.map +1 -1
  65. package/dist/core/Engine.js +620 -392
  66. package/dist/core/Engine.js.map +1 -1
  67. package/dist/core/Engine.sql +188 -119
  68. package/dist/core/ExecutableTools.d.ts +10 -0
  69. package/dist/core/ExecutableTools.d.ts.map +1 -0
  70. package/dist/core/ExecutableTools.js +41 -0
  71. package/dist/core/ExecutableTools.js.map +1 -0
  72. package/dist/core/ExecutorRegistry.d.ts +3 -3
  73. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  74. package/dist/core/ExecutorRegistry.js +2 -2
  75. package/dist/core/ExecutorRegistry.js.map +1 -1
  76. package/dist/core/LogBody.d.ts +4 -1
  77. package/dist/core/LogBody.d.ts.map +1 -1
  78. package/dist/core/LogBody.js +46 -2
  79. package/dist/core/LogBody.js.map +1 -1
  80. package/dist/core/LogEntryProjection.d.ts +14 -0
  81. package/dist/core/LogEntryProjection.d.ts.map +1 -0
  82. package/dist/core/LogEntryProjection.js +38 -0
  83. package/dist/core/LogEntryProjection.js.map +1 -0
  84. package/dist/core/LoopLifecycle.d.ts +1 -2
  85. package/dist/core/LoopLifecycle.d.ts.map +1 -1
  86. package/dist/core/LoopLifecycle.js +1 -4
  87. package/dist/core/LoopLifecycle.js.map +1 -1
  88. package/dist/core/LoopLifecycle.sql +1 -4
  89. package/dist/core/ModelCall.d.ts +27 -0
  90. package/dist/core/ModelCall.d.ts.map +1 -0
  91. package/dist/core/ModelCall.js +125 -0
  92. package/dist/core/ModelCall.js.map +1 -0
  93. package/dist/core/PacketBuilder.d.ts +13 -12
  94. package/dist/core/PacketBuilder.d.ts.map +1 -1
  95. package/dist/core/PacketBuilder.js +146 -118
  96. package/dist/core/PacketBuilder.js.map +1 -1
  97. package/dist/core/ProblemLog.d.ts.map +1 -1
  98. package/dist/core/ProblemLog.js +1 -0
  99. package/dist/core/ProblemLog.js.map +1 -1
  100. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  101. package/dist/core/ProposalLifecycle.js +3 -4
  102. package/dist/core/ProposalLifecycle.js.map +1 -1
  103. package/dist/core/ProviderInstantiate.d.ts.map +1 -1
  104. package/dist/core/ProviderInstantiate.js +23 -6
  105. package/dist/core/ProviderInstantiate.js.map +1 -1
  106. package/dist/core/SchemeRegistry.js +7 -7
  107. package/dist/core/SchemeRegistry.js.map +1 -1
  108. package/dist/core/StrikeRail.d.ts +5 -2
  109. package/dist/core/StrikeRail.d.ts.map +1 -1
  110. package/dist/core/StrikeRail.js +10 -7
  111. package/dist/core/StrikeRail.js.map +1 -1
  112. package/dist/core/TerminalResult.d.ts +16 -0
  113. package/dist/core/TerminalResult.d.ts.map +1 -0
  114. package/dist/core/TerminalResult.js +49 -0
  115. package/dist/core/TerminalResult.js.map +1 -0
  116. package/dist/core/caps/CapsResolve.d.ts +5 -1
  117. package/dist/core/caps/CapsResolve.d.ts.map +1 -1
  118. package/dist/core/caps/CapsResolve.js +9 -5
  119. package/dist/core/caps/CapsResolve.js.map +1 -1
  120. package/dist/core/caps/DbChannelCaps.d.ts +1 -1
  121. package/dist/core/caps/DbChannelCaps.d.ts.map +1 -1
  122. package/dist/core/caps/DbChannelCaps.js +6 -4
  123. package/dist/core/caps/DbChannelCaps.js.map +1 -1
  124. package/dist/core/caps/DbEntryCaps.d.ts +2 -1
  125. package/dist/core/caps/DbEntryCaps.d.ts.map +1 -1
  126. package/dist/core/caps/DbEntryCaps.js +21 -8
  127. package/dist/core/caps/DbEntryCaps.js.map +1 -1
  128. package/dist/core/caps/DbNotifyCaps.d.ts +1 -1
  129. package/dist/core/caps/DbNotifyCaps.d.ts.map +1 -1
  130. package/dist/core/caps/DbNotifyCaps.js +6 -4
  131. package/dist/core/caps/DbNotifyCaps.js.map +1 -1
  132. package/dist/core/caps/DbProjectionCaps.d.ts +1 -0
  133. package/dist/core/caps/DbProjectionCaps.d.ts.map +1 -1
  134. package/dist/core/caps/DbProjectionCaps.js +29 -0
  135. package/dist/core/caps/DbProjectionCaps.js.map +1 -1
  136. package/dist/core/caps/DbSubscriptionCaps.d.ts +4 -6
  137. package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -1
  138. package/dist/core/caps/DbSubscriptionCaps.js +27 -13
  139. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
  140. package/dist/core/caps/SchemeCtxImpl.d.ts +11 -3
  141. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  142. package/dist/core/caps/SchemeCtxImpl.js +10 -8
  143. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  144. package/dist/core/fork.d.ts.map +1 -1
  145. package/dist/core/fork.js +23 -7
  146. package/dist/core/fork.js.map +1 -1
  147. package/dist/core/fork.sql +35 -14
  148. package/dist/core/git-iso.d.ts +3 -2
  149. package/dist/core/git-iso.d.ts.map +1 -1
  150. package/dist/core/git-iso.js +27 -12
  151. package/dist/core/git-iso.js.map +1 -1
  152. package/dist/core/git-membership.d.ts +1 -0
  153. package/dist/core/git-membership.d.ts.map +1 -1
  154. package/dist/core/git-membership.js +87 -26
  155. package/dist/core/git-membership.js.map +1 -1
  156. package/dist/core/git-state.d.ts +8 -1
  157. package/dist/core/git-state.d.ts.map +1 -1
  158. package/dist/core/git-state.js +36 -17
  159. package/dist/core/git-state.js.map +1 -1
  160. package/dist/core/namespace.d.ts +1 -0
  161. package/dist/core/namespace.d.ts.map +1 -1
  162. package/dist/core/namespace.js +20 -0
  163. package/dist/core/namespace.js.map +1 -1
  164. package/dist/core/optimistic-settlement.d.ts +2 -0
  165. package/dist/core/optimistic-settlement.d.ts.map +1 -0
  166. package/dist/core/optimistic-settlement.js +14 -0
  167. package/dist/core/optimistic-settlement.js.map +1 -0
  168. package/dist/core/packet-wire.d.ts +5 -2
  169. package/dist/core/packet-wire.d.ts.map +1 -1
  170. package/dist/core/packet-wire.js +318 -114
  171. package/dist/core/packet-wire.js.map +1 -1
  172. package/dist/core/provider-accounting.d.ts +24 -0
  173. package/dist/core/provider-accounting.d.ts.map +1 -0
  174. package/dist/core/provider-accounting.js +76 -0
  175. package/dist/core/provider-accounting.js.map +1 -0
  176. package/dist/core/results.d.ts +4 -2
  177. package/dist/core/results.d.ts.map +1 -1
  178. package/dist/core/results.js +6 -0
  179. package/dist/core/results.js.map +1 -1
  180. package/dist/core/scheme-types.d.ts +0 -12
  181. package/dist/core/scheme-types.d.ts.map +1 -1
  182. package/dist/core/scheme-types.js.map +1 -1
  183. package/dist/core/search-gate.d.ts +2 -2
  184. package/dist/core/search-gate.d.ts.map +1 -1
  185. package/dist/core/search-gate.js +8 -8
  186. package/dist/core/search-gate.js.map +1 -1
  187. package/dist/core/token-ruler.js +1 -1
  188. package/dist/core/token-ruler.js.map +1 -1
  189. package/dist/core/turn-scheduler.js +1 -1
  190. package/dist/core/turn-scheduler.js.map +1 -1
  191. package/dist/core/worker-ops.sql +4 -4
  192. package/dist/core/workspace-settings.js +1 -1
  193. package/dist/core/workspace-settings.js.map +1 -1
  194. package/dist/digest/Digest.d.ts.map +1 -1
  195. package/dist/digest/Digest.js +349 -76
  196. package/dist/digest/Digest.js.map +1 -1
  197. package/dist/digest/digest.sql +42 -16
  198. package/dist/schemes/Exec.d.ts +4 -4
  199. package/dist/schemes/Exec.d.ts.map +1 -1
  200. package/dist/schemes/Exec.js +167 -134
  201. package/dist/schemes/Exec.js.map +1 -1
  202. package/dist/schemes/ExecOutputScheme.d.ts +4 -5
  203. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
  204. package/dist/schemes/ExecOutputScheme.js +12 -20
  205. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  206. package/dist/schemes/File.d.ts +5 -6
  207. package/dist/schemes/File.d.ts.map +1 -1
  208. package/dist/schemes/File.js +35 -41
  209. package/dist/schemes/File.js.map +1 -1
  210. package/dist/schemes/Log.d.ts +16 -5
  211. package/dist/schemes/Log.d.ts.map +1 -1
  212. package/dist/schemes/Log.js +229 -262
  213. package/dist/schemes/Log.js.map +1 -1
  214. package/dist/schemes/Log.sql +50 -27
  215. package/dist/schemes/Prompt.d.ts +3 -4
  216. package/dist/schemes/Prompt.d.ts.map +1 -1
  217. package/dist/schemes/Prompt.js +5 -5
  218. package/dist/schemes/Prompt.js.map +1 -1
  219. package/dist/schemes/Skill.d.ts +4 -5
  220. package/dist/schemes/Skill.d.ts.map +1 -1
  221. package/dist/schemes/Skill.js +3 -3
  222. package/dist/schemes/Skill.js.map +1 -1
  223. package/dist/schemes/Worker.d.ts +7 -7
  224. package/dist/schemes/Worker.d.ts.map +1 -1
  225. package/dist/schemes/Worker.js +99 -84
  226. package/dist/schemes/Worker.js.map +1 -1
  227. package/dist/schemes/_entry-crud.d.ts +2 -2
  228. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  229. package/dist/schemes/_entry-crud.js +29 -10
  230. package/dist/schemes/_entry-crud.js.map +1 -1
  231. package/dist/schemes/_entry-crud.sql +11 -17
  232. package/dist/schemes/_entry-find.d.ts +41 -14
  233. package/dist/schemes/_entry-find.d.ts.map +1 -1
  234. package/dist/schemes/_entry-find.js +159 -166
  235. package/dist/schemes/_entry-find.js.map +1 -1
  236. package/dist/schemes/_entry-find.sql +2 -21
  237. package/dist/schemes/_entry-graph.sql +1 -1
  238. package/dist/schemes/_entry-manifest.d.ts +5 -17
  239. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  240. package/dist/schemes/_entry-manifest.js +28 -24
  241. package/dist/schemes/_entry-manifest.js.map +1 -1
  242. package/dist/schemes/_entry-ops.d.ts +13 -8
  243. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  244. package/dist/schemes/_entry-ops.js +98 -119
  245. package/dist/schemes/_entry-ops.js.map +1 -1
  246. package/dist/schemes/_entry-ops.sql +31 -6
  247. package/dist/schemes/_entry-semantic.d.ts +5 -7
  248. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  249. package/dist/schemes/_entry-semantic.js +10 -31
  250. package/dist/schemes/_entry-semantic.js.map +1 -1
  251. package/dist/schemes/_entry-semantic.sql +2 -2
  252. package/dist/schemes/_entry-send.js +4 -4
  253. package/dist/schemes/_entry-send.js.map +1 -1
  254. package/dist/schemes/_search-index.d.ts.map +1 -1
  255. package/dist/schemes/_search-index.js +16 -6
  256. package/dist/schemes/_search-index.js.map +1 -1
  257. package/dist/schemes/exec-abort.js +2 -2
  258. package/dist/schemes/exec-abort.js.map +1 -1
  259. package/dist/server/BranchBatches.d.ts +1 -0
  260. package/dist/server/BranchBatches.d.ts.map +1 -1
  261. package/dist/server/BranchBatches.js +1 -0
  262. package/dist/server/BranchBatches.js.map +1 -1
  263. package/dist/server/Daemon.d.ts +6 -8
  264. package/dist/server/Daemon.d.ts.map +1 -1
  265. package/dist/server/Daemon.js +238 -68
  266. package/dist/server/Daemon.js.map +1 -1
  267. package/dist/server/DaemonModule.d.ts +3 -3
  268. package/dist/server/DaemonModule.d.ts.map +1 -1
  269. package/dist/server/branch-batch.sql +3 -1
  270. package/dist/server/client-input.d.ts +2 -1
  271. package/dist/server/client-input.d.ts.map +1 -1
  272. package/dist/server/client-input.js +7 -1
  273. package/dist/server/client-input.js.map +1 -1
  274. package/dist/server/drain.sql +8 -7
  275. package/dist/server/envelope.d.ts +0 -2
  276. package/dist/server/envelope.d.ts.map +1 -1
  277. package/dist/server/envelope.js +0 -1
  278. package/dist/server/envelope.js.map +1 -1
  279. package/dist/server/envelope.sql +3 -4
  280. package/dist/server/lifecycle-recovery.sql +44 -1
  281. package/dist/server/logEntry.d.ts +2 -1
  282. package/dist/server/logEntry.d.ts.map +1 -1
  283. package/dist/server/logEntry.js +15 -2
  284. package/dist/server/logEntry.js.map +1 -1
  285. package/dist/server/logEntry.sql +5 -1
  286. package/dist/service.d.ts.map +1 -1
  287. package/dist/service.js +13 -1
  288. package/dist/service.js.map +1 -1
  289. package/migrations/001_schema.sql +730 -221
  290. package/package.json +53 -46
  291. package/dist/core/BudgetOverflow.d.ts +0 -23
  292. package/dist/core/BudgetOverflow.d.ts.map +0 -1
  293. package/dist/core/BudgetOverflow.js +0 -61
  294. package/dist/core/BudgetOverflow.js.map +0 -1
  295. package/dist/core/caps/DbTagCaps.d.ts +0 -10
  296. package/dist/core/caps/DbTagCaps.d.ts.map +0 -1
  297. package/dist/core/caps/DbTagCaps.js +0 -44
  298. package/dist/core/caps/DbTagCaps.js.map +0 -1
  299. package/dist/matrix/live-specimen.d.ts +0 -9
  300. package/dist/matrix/live-specimen.d.ts.map +0 -1
  301. package/dist/matrix/live-specimen.js +0 -37
  302. package/dist/matrix/live-specimen.js.map +0 -1
@@ -11,7 +11,6 @@ CREATE TABLE IF NOT EXISTS workspaces (
11
11
  version INTEGER NOT NULL DEFAULT 0 CHECK (version >= 0),
12
12
  name TEXT NOT NULL UNIQUE CHECK (length(name) > 0),
13
13
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
14
- cost_usd REAL DEFAULT 0 CHECK (cost_usd IS NULL OR cost_usd >= 0),
15
14
  project_root TEXT,
16
15
  -- {§operator-config} validated client workspace settings; each field composes
17
16
  -- with operator configuration at its owning use site.
@@ -27,9 +26,12 @@ CREATE TABLE IF NOT EXISTS workers (
27
26
  workspace_id INTEGER NOT NULL,
28
27
  name TEXT NOT NULL CHECK (length(name) > 0),
29
28
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
29
+ -- {§worker-provider-identity}: provider affinity must not collide when
30
+ -- independent databases reuse local integer ids.
31
+ provider_identity TEXT NOT NULL DEFAULT (lower(hex(randomblob(16))))
32
+ CHECK (length(provider_identity) = 32 AND provider_identity NOT GLOB '*[^0-9a-f]*'),
30
33
  -- workers fork via parent_worker_id; workspaces carry no parent — {§machine-processes-no-fork-workspace}
31
34
  parent_worker_id INTEGER CHECK (parent_worker_id IS NULL OR parent_worker_id != id),
32
- cost_usd REAL DEFAULT 0 CHECK (cost_usd IS NULL OR cost_usd >= 0),
33
35
  origin TEXT NOT NULL DEFAULT 'client' CHECK (origin IN ('model', 'client', 'plurnk')),
34
36
  -- {§methods-model-worker}: durable identity for the workspace's stable
35
37
  -- default conversation; unrelated to its human-facing, reclaimable name.
@@ -44,6 +46,7 @@ CREATE TABLE IF NOT EXISTS workers (
44
46
 
45
47
  CREATE INDEX IF NOT EXISTS workers_workspace_id_created_at ON workers (workspace_id, created_at);
46
48
  CREATE INDEX IF NOT EXISTS workers_parent_worker_id ON workers (parent_worker_id);
49
+ CREATE UNIQUE INDEX IF NOT EXISTS workers_provider_identity ON workers (provider_identity);
47
50
  CREATE UNIQUE INDEX IF NOT EXISTS workers_workspace_default_conversation
48
51
  ON workers (workspace_id) WHERE default_conversation = 1;
49
52
  -- NOT unique: a name is frozen per worker ({§machine-processes-worker-origin}) but RECLAIMABLE across
@@ -52,6 +55,13 @@ CREATE UNIQUE INDEX IF NOT EXISTS workers_workspace_default_conversation
52
55
  -- → worker_live_by_name → 409), never by this index. Indexed for the by-name resolve/spawn lookup.
53
56
  CREATE INDEX IF NOT EXISTS workers_workspace_name ON workers (workspace_id, name);
54
57
 
58
+ CREATE TRIGGER IF NOT EXISTS workers_provider_identity_immutable
59
+ BEFORE UPDATE OF provider_identity ON workers
60
+ WHEN NEW.provider_identity != OLD.provider_identity
61
+ BEGIN
62
+ SELECT RAISE(ABORT, 'workers.provider_identity is immutable');
63
+ END;
64
+
55
65
  -- {§env-delta-log-pull}: one append-only occurrence journal gives every ambient
56
66
  -- producer a shared monotonic order. It snapshots only what the observer row
57
67
  -- needs; shared-world contents remain owned by entries/files, never copied here.
@@ -70,14 +80,21 @@ CREATE TABLE IF NOT EXISTS ambient_events (
70
80
  pathname TEXT,
71
81
  rx TEXT,
72
82
  attrs TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(attrs)),
83
+ tags TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags) AND json_type(tags) = 'array'),
73
84
  status_rx INTEGER NOT NULL CHECK (status_rx BETWEEN 100 AND 599),
74
- prompt TEXT,
75
85
  terminated_by TEXT CHECK (terminated_by IS NULL OR terminated_by = 'cancel'),
76
86
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
77
87
  CHECK (
78
- (kind = 'edit' AND op = 'EDIT' AND rx IS NOT NULL AND prompt IS NULL AND terminated_by IS NULL)
88
+ (kind = 'edit' AND op = 'EDIT' AND rx IS NOT NULL AND terminated_by IS NULL)
79
89
  OR
80
- (kind = 'loop_termination' AND op = 'SEND' AND scheme = 'worker' AND prompt IS NOT NULL)
90
+ (kind = 'loop_termination'
91
+ AND op = 'SEND'
92
+ AND scheme = 'worker'
93
+ AND rx IS NOT NULL
94
+ AND json_valid(rx)
95
+ AND json_type(rx) = 'object'
96
+ AND json_type(rx, '$.status') = 'integer'
97
+ AND json_extract(rx, '$.status') = status_rx)
81
98
  ),
82
99
  FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE,
83
100
  FOREIGN KEY (producer_worker_id) REFERENCES workers(id) ON DELETE CASCADE
@@ -102,6 +119,7 @@ CREATE TABLE IF NOT EXISTS loops (
102
119
  prompt TEXT NOT NULL,
103
120
  flags TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(flags)),
104
121
  provider_spec TEXT NOT NULL DEFAULT 'null' CHECK (json_valid(provider_spec)),
122
+ child_provider_spec TEXT NOT NULL DEFAULT 'null' CHECK (json_valid(child_provider_spec)),
105
123
  max_turns INTEGER NOT NULL DEFAULT 50 CHECK (max_turns >= -1),
106
124
  -- {§methods-loop-run-open-paths}: the initial prompt frame's selected paths,
107
125
  -- held here until turn 1 materializes that frame (string[] JSON).
@@ -110,22 +128,56 @@ CREATE TABLE IF NOT EXISTS loops (
110
128
  -- complete orphan frame set of one concluded source loop.
111
129
  orphan_source_loop_id INTEGER,
112
130
  -- {§worker-scheme} loop-termination delta: terminated_at is stamped by the trigger
113
- -- below when status crosses into terminal (every death-path, uniformly);
114
- -- terminal_message is the deliverable — the SEND[200] body or the abandonment
115
- -- reason — set by the guarded terminal lifecycle transition.
131
+ -- below when status crosses into terminal (every death-path, uniformly).
116
132
  terminated_at TEXT,
117
- terminal_message TEXT,
118
- terminal_result TEXT CHECK (terminal_result IS NULL OR json_valid(terminal_result)),
133
+ terminal_result TEXT,
119
134
  -- {§loop-terminal-authorship}: 'cancel' names an external loop.cancel;
120
135
  -- NULL covers model terminals and engine verdicts whose result carries the story.
121
136
  terminated_by TEXT CHECK (terminated_by IS NULL OR terminated_by = 'cancel'),
137
+ CONSTRAINT loops_terminal_result_contract CHECK (
138
+ CASE
139
+ WHEN status IN (100, 102, 202) THEN terminal_result IS NULL
140
+ WHEN terminal_result IS NULL OR NOT json_valid(terminal_result) THEN 0
141
+ ELSE
142
+ json_type(terminal_result) IS 'object'
143
+ AND json_type(terminal_result, '$.status') IS 'integer'
144
+ AND (
145
+ json_extract(terminal_result, '$.status') = status
146
+ OR (
147
+ status = 200
148
+ AND json_extract(terminal_result, '$.status') BETWEEN 200 AND 399
149
+ AND json_extract(terminal_result, '$.status') != 202
150
+ )
151
+ OR (
152
+ status = 500
153
+ AND json_extract(terminal_result, '$.status') BETWEEN 400 AND 599
154
+ )
155
+ )
156
+ AND CASE
157
+ WHEN json_extract(terminal_result, '$.status') < 400 THEN
158
+ json_type(terminal_result, '$.problem') IS NULL
159
+ ELSE
160
+ json_type(terminal_result, '$.problem') IS 'object'
161
+ AND json_type(terminal_result, '$.problem.status') IS 'integer'
162
+ AND json_extract(terminal_result, '$.problem.status')
163
+ = json_extract(terminal_result, '$.status')
164
+ AND json_type(terminal_result, '$.problem.type') IS 'text'
165
+ AND length(json_extract(terminal_result, '$.problem.type')) > 0
166
+ AND json_type(terminal_result, '$.problem.title') IS 'text'
167
+ AND length(json_extract(terminal_result, '$.problem.title')) > 0
168
+ AND json_type(terminal_result, '$.problem.detail') IS 'text'
169
+ AND length(json_extract(terminal_result, '$.problem.detail')) > 0
170
+ AND json_type(terminal_result, '$.problem.instance') IS 'text'
171
+ AND length(json_extract(terminal_result, '$.problem.instance')) > 0
172
+ END
173
+ END
174
+ ),
122
175
  FOREIGN KEY (worker_id) REFERENCES workers(id) ON DELETE CASCADE,
123
176
  FOREIGN KEY (orphan_source_loop_id) REFERENCES loops(id)
124
177
  ) STRICT;
125
178
 
126
179
  CREATE UNIQUE INDEX IF NOT EXISTS loops_worker_id_sequence ON loops (worker_id, sequence);
127
180
  CREATE UNIQUE INDEX IF NOT EXISTS loops_orphan_source_loop_id ON loops (orphan_source_loop_id);
128
-
129
181
  -- {§worker-scheme}: a loop crossing into a terminal status stamps terminated_at, so sibling
130
182
  -- workers pull the termination as a folded ambient delta — caught uniformly across every
131
183
  -- death-path (SEND, grinder, max-turns, strike, KILL). The stamp updates terminated_at,
@@ -147,100 +199,19 @@ WHEN NEW.status IN (200, 413, 429, 499, 500, 504, 508) AND OLD.status NOT IN (20
147
199
  BEGIN
148
200
  INSERT INTO ambient_events (
149
201
  workspace_id, producer_worker_id, kind, source_record_id, source,
150
- op, scheme, pathname, rx, status_rx, prompt, terminated_by
202
+ op, scheme, pathname, rx, status_rx, terminated_by
151
203
  )
152
204
  SELECT w.workspace_id, NEW.worker_id, 'loop_termination', NEW.id, NULL,
153
- 'SEND', 'worker', '/' || w.name, NEW.terminal_message, NEW.status, NEW.prompt, NEW.terminated_by
205
+ 'SEND', 'worker', '/' || w.name, NEW.terminal_result,
206
+ json_extract(NEW.terminal_result, '$.status'), NEW.terminated_by
154
207
  FROM workers w
155
208
  WHERE w.id = NEW.worker_id;
156
209
  END;
157
210
 
158
- CREATE TRIGGER IF NOT EXISTS loops_result_contract_insert
159
- BEFORE INSERT ON loops
160
- WHEN NEW.status IN (100, 102, 200, 202, 413, 429, 499, 500, 504, 508)
161
- AND NOT (
162
- (NEW.status IN (100, 102, 202) AND NEW.terminal_result IS NULL)
163
- OR (
164
- NEW.status IN (200, 413, 429, 499, 500, 504, 508)
165
- AND NEW.terminal_result IS NOT NULL
166
- AND json_valid(NEW.terminal_result)
167
- AND json_type(NEW.terminal_result, '$.status') = 'integer'
168
- AND (
169
- json_extract(NEW.terminal_result, '$.status') = NEW.status
170
- OR (
171
- NEW.status = 200
172
- AND json_extract(NEW.terminal_result, '$.status') BETWEEN 200 AND 399
173
- AND json_extract(NEW.terminal_result, '$.status') != 202
174
- )
175
- OR (
176
- NEW.status = 500
177
- AND json_extract(NEW.terminal_result, '$.status') BETWEEN 400 AND 599
178
- )
179
- )
180
- AND (
181
- (NEW.status < 400 AND json_type(NEW.terminal_result, '$.problem') IS NULL)
182
- OR (
183
- NEW.status >= 400
184
- AND json_type(NEW.terminal_result, '$.problem') = 'object'
185
- AND json_extract(NEW.terminal_result, '$.problem.status')
186
- = json_extract(NEW.terminal_result, '$.status')
187
- AND length(json_extract(NEW.terminal_result, '$.problem.type')) > 0
188
- AND length(json_extract(NEW.terminal_result, '$.problem.title')) > 0
189
- AND length(json_extract(NEW.terminal_result, '$.problem.detail')) > 0
190
- AND length(json_extract(NEW.terminal_result, '$.problem.instance')) > 0
191
- )
192
- )
193
- )
194
- )
195
- BEGIN
196
- SELECT RAISE(ABORT, 'loop terminal result violates the operation-result contract');
197
- END;
198
-
199
- CREATE TRIGGER IF NOT EXISTS loops_result_contract_update
200
- BEFORE UPDATE OF status, terminal_result ON loops
201
- WHEN NEW.status IN (100, 102, 200, 202, 413, 429, 499, 500, 504, 508)
202
- AND NOT (
203
- (NEW.status IN (100, 102, 202) AND NEW.terminal_result IS NULL)
204
- OR (
205
- NEW.status IN (200, 413, 429, 499, 500, 504, 508)
206
- AND NEW.terminal_result IS NOT NULL
207
- AND json_valid(NEW.terminal_result)
208
- AND json_type(NEW.terminal_result, '$.status') = 'integer'
209
- AND (
210
- json_extract(NEW.terminal_result, '$.status') = NEW.status
211
- OR (
212
- NEW.status = 200
213
- AND json_extract(NEW.terminal_result, '$.status') BETWEEN 200 AND 399
214
- AND json_extract(NEW.terminal_result, '$.status') != 202
215
- )
216
- OR (
217
- NEW.status = 500
218
- AND json_extract(NEW.terminal_result, '$.status') BETWEEN 400 AND 599
219
- )
220
- )
221
- AND (
222
- (NEW.status < 400 AND json_type(NEW.terminal_result, '$.problem') IS NULL)
223
- OR (
224
- NEW.status >= 400
225
- AND json_type(NEW.terminal_result, '$.problem') = 'object'
226
- AND json_extract(NEW.terminal_result, '$.problem.status')
227
- = json_extract(NEW.terminal_result, '$.status')
228
- AND length(json_extract(NEW.terminal_result, '$.problem.type')) > 0
229
- AND length(json_extract(NEW.terminal_result, '$.problem.title')) > 0
230
- AND length(json_extract(NEW.terminal_result, '$.problem.detail')) > 0
231
- AND length(json_extract(NEW.terminal_result, '$.problem.instance')) > 0
232
- )
233
- )
234
- )
235
- )
236
- BEGIN
237
- SELECT RAISE(ABORT, 'loop terminal result violates the operation-result contract');
238
- END;
239
-
240
211
  -- turns
241
- -- finish_reason / model: provider-call metadata from the provider response contract.
242
- -- Properties of the call, not of the model's emission payload — kept on
243
- -- the Turn row alongside usage rather than nested into packet.assistant.
212
+ -- finish_reason / model: accepted provider-call metadata from the provider
213
+ -- response contract. Physical request accounting is normalized beneath the
214
+ -- logical emission attempt that caused it.
244
215
  CREATE TABLE IF NOT EXISTS turns (
245
216
  id INTEGER NOT NULL PRIMARY KEY,
246
217
  version INTEGER NOT NULL DEFAULT 0 CHECK (version >= 0),
@@ -248,12 +219,6 @@ CREATE TABLE IF NOT EXISTS turns (
248
219
  sequence INTEGER NOT NULL CHECK (sequence >= 1),
249
220
  timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
250
221
  status INTEGER NOT NULL CHECK (status BETWEEN 100 AND 599),
251
- usage_prompt INTEGER NOT NULL DEFAULT 0 CHECK (usage_prompt >= 0),
252
- usage_completion INTEGER NOT NULL DEFAULT 0 CHECK (usage_completion >= 0),
253
- usage_reasoning INTEGER NOT NULL DEFAULT 0 CHECK (usage_reasoning >= 0),
254
- usage_cached INTEGER NOT NULL DEFAULT 0 CHECK (usage_cached >= 0),
255
- usage_cost TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(usage_cost) AND json_type(usage_cost) = 'array'),
256
- usage_cost_usd REAL DEFAULT 0 CHECK (usage_cost_usd IS NULL OR usage_cost_usd >= 0),
257
222
  -- Effective packet allowance for this turn's provider and policy; NULL is
258
223
  -- uncapped or unknown. {§tokenomics-client-gauge}
259
224
  usage_prompt_budget INTEGER CHECK (usage_prompt_budget IS NULL OR usage_prompt_budget >= 1),
@@ -298,38 +263,254 @@ CREATE TABLE IF NOT EXISTS turns (
298
263
  CREATE UNIQUE INDEX IF NOT EXISTS turns_loop_id_sequence ON turns (loop_id, sequence);
299
264
  CREATE INDEX IF NOT EXISTS turns_timestamp ON turns (timestamp);
300
265
 
301
- -- Provider calls are attempts beneath one engine turn. Rejected and interrupted
302
- -- response evidence persists without becoming a turn or entering model-visible
303
- -- history.
304
- CREATE TABLE IF NOT EXISTS turn_attempts (
266
+ -- One logical provider.generate call. Emission attempts and BARE inferences
267
+ -- share response/failure evidence and cardinal physical request accounting;
268
+ -- operation-specific semantics live in their specializing relations.
269
+ CREATE TABLE IF NOT EXISTS model_calls (
305
270
  id INTEGER NOT NULL PRIMARY KEY,
306
271
  turn_id INTEGER NOT NULL,
307
272
  sequence INTEGER NOT NULL CHECK (sequence >= 1),
308
- accepted INTEGER NOT NULL CHECK (accepted IN (0, 1)),
309
- response TEXT NOT NULL CHECK (json_valid(response)),
310
- parse_errors TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(parse_errors)),
311
- -- Exact opaque tag set forwarded with this response-bearing provider call.
273
+ kind TEXT NOT NULL CHECK (kind IN ('emission', 'bare')),
274
+ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'response', 'error')),
275
+ response TEXT CHECK (response IS NULL OR json_valid(response)),
276
+ failure TEXT CHECK (failure IS NULL OR json_valid(failure)),
277
+ -- Exact opaque tag set forwarded with this provider call.
312
278
  -- {§attribution}
313
279
  attributions TEXT NOT NULL DEFAULT '[]' CHECK (
314
280
  json_valid(attributions) AND json_type(attributions) = 'array'
315
281
  ),
316
- usage_prompt INTEGER NOT NULL DEFAULT 0 CHECK (usage_prompt >= 0),
317
- usage_completion INTEGER NOT NULL DEFAULT 0 CHECK (usage_completion >= 0),
318
- usage_reasoning INTEGER NOT NULL DEFAULT 0 CHECK (usage_reasoning >= 0),
319
- usage_cached INTEGER NOT NULL DEFAULT 0 CHECK (usage_cached >= 0),
320
- usage_cost TEXT NOT NULL CHECK (json_valid(usage_cost) AND json_type(usage_cost) = 'object'),
321
- usage_cost_usd REAL CHECK (usage_cost_usd IS NULL OR usage_cost_usd >= 0),
322
282
  finish_reason TEXT,
323
283
  model TEXT NOT NULL CHECK (length(model) >= 1),
324
284
  timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
285
+ completed_at TEXT,
286
+ CHECK (
287
+ (state = 'pending'
288
+ AND response IS NULL AND failure IS NULL
289
+ AND finish_reason IS NULL AND completed_at IS NULL)
290
+ OR
291
+ (state = 'response'
292
+ AND response IS NOT NULL
293
+ AND completed_at IS NOT NULL)
294
+ OR
295
+ (state = 'error'
296
+ AND response IS NULL AND failure IS NOT NULL
297
+ AND completed_at IS NOT NULL)
298
+ ),
325
299
  UNIQUE (turn_id, sequence),
326
300
  FOREIGN KEY (turn_id) REFERENCES turns(id) ON DELETE CASCADE
327
301
  ) STRICT;
328
302
 
329
- CREATE INDEX IF NOT EXISTS turn_attempts_turn_id ON turn_attempts (turn_id);
330
- CREATE UNIQUE INDEX IF NOT EXISTS turn_attempts_one_accepted_per_turn
331
- ON turn_attempts (turn_id)
332
- WHERE accepted = 1;
303
+ CREATE INDEX IF NOT EXISTS model_calls_turn_id ON model_calls (turn_id, sequence);
304
+
305
+ CREATE TRIGGER IF NOT EXISTS model_calls_request_identity_immutable
306
+ BEFORE UPDATE OF turn_id, sequence, kind, attributions, timestamp ON model_calls
307
+ BEGIN
308
+ SELECT RAISE(ABORT, 'model call request identity is immutable');
309
+ END;
310
+
311
+ CREATE TRIGGER IF NOT EXISTS model_calls_state_forward_only
312
+ BEFORE UPDATE OF state ON model_calls
313
+ WHEN NOT (
314
+ NEW.state = OLD.state
315
+ OR (OLD.state = 'pending' AND NEW.state IN ('response', 'error'))
316
+ )
317
+ BEGIN
318
+ SELECT RAISE(ABORT, 'model call state may only close once');
319
+ END;
320
+
321
+ CREATE TRIGGER IF NOT EXISTS model_calls_observation_immutable
322
+ BEFORE UPDATE OF response, failure, finish_reason, model, completed_at
323
+ ON model_calls
324
+ WHEN OLD.state != 'pending'
325
+ BEGIN
326
+ SELECT RAISE(ABORT, 'model call observation is immutable');
327
+ END;
328
+
329
+ -- Emission admission specializes one model call without re-owning its response,
330
+ -- provider identity, ordering, or accounting evidence.
331
+ CREATE TABLE IF NOT EXISTS turn_attempts (
332
+ id INTEGER NOT NULL PRIMARY KEY,
333
+ model_call_id INTEGER NOT NULL UNIQUE,
334
+ accepted INTEGER CHECK (accepted IS NULL OR accepted IN (0, 1)),
335
+ parse_errors TEXT NOT NULL DEFAULT '[]' CHECK (
336
+ json_valid(parse_errors) AND json_type(parse_errors) = 'array'
337
+ ),
338
+ FOREIGN KEY (model_call_id) REFERENCES model_calls(id) ON DELETE CASCADE
339
+ ) STRICT;
340
+
341
+ CREATE TRIGGER IF NOT EXISTS turn_attempts_request_identity_immutable
342
+ BEFORE UPDATE OF model_call_id ON turn_attempts
343
+ BEGIN
344
+ SELECT RAISE(ABORT, 'emission attempt identity is immutable');
345
+ END;
346
+
347
+ CREATE TRIGGER IF NOT EXISTS turn_attempts_emission_call_only
348
+ BEFORE INSERT ON turn_attempts
349
+ WHEN COALESCE((SELECT kind = 'emission' FROM model_calls WHERE id = NEW.model_call_id), 0) != 1
350
+ BEGIN
351
+ SELECT RAISE(ABORT, 'turn attempt requires an emission model call');
352
+ END;
353
+
354
+ CREATE TRIGGER IF NOT EXISTS turn_attempts_classification_after_response
355
+ BEFORE UPDATE OF accepted, parse_errors ON turn_attempts
356
+ WHEN COALESCE((SELECT state = 'response' FROM model_calls WHERE id = OLD.model_call_id), 0) != 1
357
+ BEGIN
358
+ SELECT RAISE(ABORT, 'emission classification requires model response evidence');
359
+ END;
360
+
361
+ CREATE TRIGGER IF NOT EXISTS turn_attempts_classification_once
362
+ BEFORE UPDATE OF accepted, parse_errors
363
+ ON turn_attempts
364
+ WHEN OLD.accepted IS NOT NULL
365
+ BEGIN
366
+ SELECT RAISE(ABORT, 'emission attempt classification is immutable');
367
+ END;
368
+
369
+ CREATE TRIGGER IF NOT EXISTS turn_attempts_one_accepted_insert
370
+ BEFORE INSERT ON turn_attempts
371
+ WHEN NEW.accepted = 1 AND EXISTS (
372
+ SELECT 1
373
+ FROM turn_attempts existing
374
+ JOIN model_calls old_call ON old_call.id = existing.model_call_id
375
+ JOIN model_calls new_call ON new_call.id = NEW.model_call_id
376
+ WHERE old_call.turn_id = new_call.turn_id AND existing.accepted = 1
377
+ )
378
+ BEGIN
379
+ SELECT RAISE(ABORT, 'one emission may be accepted per turn');
380
+ END;
381
+
382
+ CREATE TRIGGER IF NOT EXISTS turn_attempts_one_accepted_update
383
+ BEFORE UPDATE OF accepted ON turn_attempts
384
+ WHEN NEW.accepted = 1 AND EXISTS (
385
+ SELECT 1
386
+ FROM turn_attempts existing
387
+ JOIN model_calls old_call ON old_call.id = existing.model_call_id
388
+ JOIN model_calls new_call ON new_call.id = NEW.model_call_id
389
+ WHERE old_call.turn_id = new_call.turn_id
390
+ AND existing.id != OLD.id
391
+ AND existing.accepted = 1
392
+ )
393
+ BEGIN
394
+ SELECT RAISE(ABORT, 'one emission may be accepted per turn');
395
+ END;
396
+
397
+ -- {§provider-request-accounting}: one row is opened before one physical
398
+ -- provider request and settled exactly once with the evidence from that request.
399
+ -- Monetary values remain canonical decimal strings; SQLite REAL is never an
400
+ -- accounting representation.
401
+ CREATE TABLE IF NOT EXISTS provider_requests (
402
+ id INTEGER NOT NULL PRIMARY KEY,
403
+ model_call_id INTEGER NOT NULL,
404
+ sequence INTEGER NOT NULL CHECK (sequence >= 1),
405
+ provider TEXT NOT NULL CHECK (length(provider) > 0),
406
+ model TEXT NOT NULL CHECK (length(model) > 0),
407
+ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'settled')),
408
+ outcome TEXT CHECK (outcome IN ('response', 'error')),
409
+ status INTEGER CHECK (status IS NULL OR status BETWEEN 100 AND 599),
410
+ usage_input INTEGER CHECK (usage_input IS NULL OR usage_input >= 0),
411
+ usage_output INTEGER CHECK (usage_output IS NULL OR usage_output >= 0),
412
+ usage_total INTEGER CHECK (usage_total IS NULL OR usage_total >= 0),
413
+ usage_input_no_cache INTEGER CHECK (usage_input_no_cache IS NULL OR usage_input_no_cache >= 0),
414
+ usage_input_cache_read INTEGER CHECK (usage_input_cache_read IS NULL OR usage_input_cache_read >= 0),
415
+ usage_input_cache_write INTEGER CHECK (usage_input_cache_write IS NULL OR usage_input_cache_write >= 0),
416
+ usage_output_text INTEGER CHECK (usage_output_text IS NULL OR usage_output_text >= 0),
417
+ usage_output_reasoning INTEGER CHECK (usage_output_reasoning IS NULL OR usage_output_reasoning >= 0),
418
+ cost_kind TEXT CHECK (cost_kind IN ('charged', 'estimated', 'unknown')),
419
+ cost_amount TEXT,
420
+ cost_currency TEXT,
421
+ cost_usd_equivalent TEXT,
422
+ cost_source TEXT,
423
+ cost_reason TEXT,
424
+ started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
425
+ completed_at TEXT,
426
+ CHECK (
427
+ (state = 'pending'
428
+ AND outcome IS NULL AND status IS NULL
429
+ AND usage_input IS NULL AND usage_output IS NULL AND usage_total IS NULL
430
+ AND usage_input_no_cache IS NULL AND usage_input_cache_read IS NULL
431
+ AND usage_input_cache_write IS NULL AND usage_output_text IS NULL
432
+ AND usage_output_reasoning IS NULL
433
+ AND cost_kind IS NULL AND cost_amount IS NULL AND cost_currency IS NULL
434
+ AND cost_usd_equivalent IS NULL AND cost_source IS NULL AND cost_reason IS NULL
435
+ AND completed_at IS NULL)
436
+ OR
437
+ (state = 'settled' AND outcome IS NOT NULL AND cost_kind IS NOT NULL AND completed_at IS NOT NULL)
438
+ ),
439
+ CHECK (
440
+ cost_amount IS NULL OR (
441
+ length(cost_amount) > 0
442
+ AND cost_amount NOT GLOB '*[^0-9.]*'
443
+ AND cost_amount NOT LIKE '.%'
444
+ AND cost_amount NOT LIKE '%.'
445
+ AND cost_amount NOT LIKE '%.%.%'
446
+ AND (cost_amount = '0' OR cost_amount NOT GLOB '0[0-9]*')
447
+ )
448
+ ),
449
+ CHECK (
450
+ cost_usd_equivalent IS NULL OR (
451
+ length(cost_usd_equivalent) > 0
452
+ AND cost_usd_equivalent NOT GLOB '*[^0-9.]*'
453
+ AND cost_usd_equivalent NOT LIKE '.%'
454
+ AND cost_usd_equivalent NOT LIKE '%.'
455
+ AND cost_usd_equivalent NOT LIKE '%.%.%'
456
+ AND (cost_usd_equivalent = '0' OR cost_usd_equivalent NOT GLOB '0[0-9]*')
457
+ )
458
+ ),
459
+ CHECK (
460
+ cost_currency IS NULL OR (
461
+ length(cost_currency) BETWEEN 3 AND 12
462
+ AND substr(cost_currency, 1, 1) GLOB '[A-Z]'
463
+ AND cost_currency NOT GLOB '*[^A-Z0-9]*'
464
+ )
465
+ ),
466
+ CHECK (
467
+ cost_kind IS NULL
468
+ OR (cost_kind = 'charged'
469
+ AND cost_amount IS NOT NULL AND cost_currency IS NOT NULL
470
+ AND cost_source IS NOT NULL AND length(cost_source) > 0
471
+ AND cost_reason IS NULL)
472
+ OR (cost_kind = 'estimated'
473
+ AND cost_amount IS NOT NULL AND cost_currency IS NOT NULL
474
+ AND cost_usd_equivalent IS NULL
475
+ AND cost_source IS NOT NULL AND length(cost_source) > 0
476
+ AND cost_reason IS NULL)
477
+ OR (cost_kind = 'unknown'
478
+ AND cost_amount IS NULL AND cost_currency IS NULL
479
+ AND cost_usd_equivalent IS NULL AND cost_source IS NULL
480
+ AND cost_reason IS NOT NULL AND length(cost_reason) > 0)
481
+ ),
482
+ UNIQUE (model_call_id, sequence),
483
+ FOREIGN KEY (model_call_id) REFERENCES model_calls(id) ON DELETE CASCADE
484
+ ) STRICT;
485
+
486
+ CREATE INDEX IF NOT EXISTS provider_requests_model_call_id
487
+ ON provider_requests (model_call_id, sequence);
488
+
489
+ CREATE TRIGGER IF NOT EXISTS provider_requests_identity_immutable
490
+ BEFORE UPDATE OF model_call_id, sequence, provider, model, started_at ON provider_requests
491
+ BEGIN
492
+ SELECT RAISE(ABORT, 'provider request identity is immutable');
493
+ END;
494
+
495
+ CREATE TRIGGER IF NOT EXISTS provider_requests_state_forward_only
496
+ BEFORE UPDATE OF state ON provider_requests
497
+ WHEN NOT (NEW.state = OLD.state OR (OLD.state = 'pending' AND NEW.state = 'settled'))
498
+ BEGIN
499
+ SELECT RAISE(ABORT, 'provider request may only settle once');
500
+ END;
501
+
502
+ CREATE TRIGGER IF NOT EXISTS provider_requests_settlement_immutable
503
+ BEFORE UPDATE OF outcome, status,
504
+ usage_input, usage_output, usage_total,
505
+ usage_input_no_cache, usage_input_cache_read, usage_input_cache_write,
506
+ usage_output_text, usage_output_reasoning,
507
+ cost_kind, cost_amount, cost_currency, cost_usd_equivalent,
508
+ cost_source, cost_reason, completed_at
509
+ ON provider_requests
510
+ WHEN OLD.state != 'pending'
511
+ BEGIN
512
+ SELECT RAISE(ABORT, 'provider request settlement is immutable');
513
+ END;
333
514
 
334
515
  -- derivations
335
516
  -- Content-addressed deep projections. Entries point at a COMPLETE artifact by
@@ -342,6 +523,7 @@ CREATE TABLE IF NOT EXISTS derivations (
342
523
  state TEXT NOT NULL DEFAULT 'building' CHECK (state IN ('building', 'complete')),
343
524
  disposition TEXT CHECK (disposition IN ('vector', 'lexical', 'excluded', 'nonsemantic', 'failed')),
344
525
  reason TEXT,
526
+ parse_issues INTEGER CHECK (parse_issues IS NULL OR parse_issues > 0),
345
527
  CHECK ((state = 'building' AND disposition IS NULL) OR (state = 'complete' AND disposition IS NOT NULL))
346
528
  ) STRICT;
347
529
 
@@ -377,7 +559,8 @@ CREATE TABLE IF NOT EXISTS entries (
377
559
  -- unchanged entry is skipped, never re-metadatafied every turn.
378
560
  deep_hash TEXT,
379
561
  -- SPEC {§membership-change-gated-sync} — the per-member sync stat-detect:
380
- -- "<mtimeMs>:<size>" of the disk file at its last materialization. The pre-turn
562
+ -- "<mtimeMs>:<size>" of the disk file at its last materialization, or `absent`
563
+ -- after an observed deletion. The pre-turn
381
564
  -- sync stat()s every member but re-reads/re-tokenizes/rewrites only one whose
382
565
  -- signature changed; an unchanged member is a no-op. NULL = never synced.
383
566
  synced_sig TEXT,
@@ -409,6 +592,36 @@ CREATE TABLE IF NOT EXISTS entry_channels (
409
592
  -- NULL). The per-tokenizer token cache it once keyed was retired — {§tokenomics-agnostic-ruler}.
410
593
  content_hash TEXT,
411
594
  state TEXT NOT NULL DEFAULT 'static' CHECK (state IN ('static', 'active', 'closed', 'errored')),
595
+ -- Exact terminal producer evidence for this representation channel. NULL is
596
+ -- the ordinary implicit {status:200}; selection/projection metadata belongs
597
+ -- to core and is never stored here.
598
+ producer_result TEXT,
599
+ CONSTRAINT entry_channel_producer_result_contract CHECK (
600
+ CASE
601
+ WHEN producer_result IS NULL THEN 1
602
+ WHEN NOT json_valid(producer_result) THEN 0
603
+ ELSE
604
+ json_type(producer_result) IS 'object'
605
+ AND json_type(producer_result, '$.status') IS 'integer'
606
+ AND json_extract(producer_result, '$.status') BETWEEN 200 AND 599
607
+ AND json_extract(producer_result, '$.status') != 202
608
+ AND CASE
609
+ WHEN json_extract(producer_result, '$.status') < 400 THEN
610
+ json_type(producer_result, '$.problem') IS NULL
611
+ ELSE
612
+ json_type(producer_result, '$.problem') IS 'object'
613
+ AND json_type(producer_result, '$.problem.status') IS 'integer'
614
+ AND json_extract(producer_result, '$.problem.status')
615
+ = json_extract(producer_result, '$.status')
616
+ AND json_type(producer_result, '$.problem.type') IS 'text'
617
+ AND length(json_extract(producer_result, '$.problem.type')) > 0
618
+ AND json_type(producer_result, '$.problem.title') IS 'text'
619
+ AND length(json_extract(producer_result, '$.problem.title')) > 0
620
+ AND json_type(producer_result, '$.problem.detail') IS 'text'
621
+ AND length(json_extract(producer_result, '$.problem.detail')) > 0
622
+ END
623
+ END
624
+ ),
412
625
  PRIMARY KEY (entry_id, name),
413
626
  FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
414
627
  ) STRICT, WITHOUT ROWID;
@@ -422,14 +635,11 @@ BEGIN
422
635
  UPDATE entries SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = NEW.entry_id;
423
636
  END;
424
637
 
425
- CREATE TABLE IF NOT EXISTS entry_tags (
426
- entry_id INTEGER NOT NULL,
427
- tag TEXT NOT NULL CHECK (length(tag) > 0),
428
- PRIMARY KEY (entry_id, tag),
429
- FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
430
- ) STRICT, WITHOUT ROWID;
431
-
432
- CREATE INDEX IF NOT EXISTS entry_tags_tag ON entry_tags (tag);
638
+ CREATE TRIGGER IF NOT EXISTS entries_touch_on_channel_update
639
+ AFTER UPDATE ON entry_channels
640
+ BEGIN
641
+ UPDATE entries SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = NEW.entry_id;
642
+ END;
433
643
 
434
644
  -- symbol_defs
435
645
  -- @graph NODES ({§relation-indexed-dialects}). Code symbol definitions, populated
@@ -521,18 +731,20 @@ CREATE TABLE IF NOT EXISTS log_entries (
521
731
  ambient_event_id INTEGER REFERENCES ambient_events(id),
522
732
  -- Search derivation attached to this durable log result, when available.
523
733
  deep_hash TEXT REFERENCES derivations(deep_hash),
734
+ -- Exact logical provider call represented by a BARE result or model-emission
735
+ -- mirror. Other operation and ambient rows carry no model-call identity.
736
+ model_call_id INTEGER REFERENCES model_calls(id),
524
737
 
525
738
  -- 'error' is an ACTIONLESS row ({§operation-results} — errors are log items): a parse failure that
526
739
  -- produced no op still records a log entry (op='error', status_rx≥400, no target) so the model
527
740
  -- can fold/kill/recall its own mistakes like any other log row — one budget surface, the log.
528
- -- 'model' is an ACTIONLESS row too ({§model-entry}): the model's own verbatim emission, mirrored
529
- -- back as a foldable log item so it can finally SEE its prior output (born folded; the turn-0
530
- -- exemplar is born open). text/vnd.plurnk-typed; OPEN/FOLD/KILL-able like any row.
741
+ -- Actionless artifacts carry NULL here: kernel-authored worker initialization
742
+ -- ({§worker-initialization-entry}) or a model emission ({§model-entry}).
531
743
  -- No op enum here: the grammar op set is grammar's contract (PlurnkOp), and this column is written
532
- -- only by the PlurnkOp-typed engine (grammar ops) or with the two service markers ('error','model').
744
+ -- only by the PlurnkOp-typed engine (grammar ops), service row selectors, or NULL for no op.
533
745
  -- A SQL enum would be a hand-copy of grammar's op list that silently goes stale on every new verb
534
746
  -- (it did — FORK/WORK). Validity lives at the parse + type layer, not duplicated in DDL.
535
- op TEXT NOT NULL,
747
+ op TEXT,
536
748
  suffix TEXT NOT NULL DEFAULT '',
537
749
  signal TEXT CHECK (signal IS NULL OR json_valid(signal)),
538
750
 
@@ -564,6 +776,10 @@ CREATE TABLE IF NOT EXISTS log_entries (
564
776
 
565
777
  expanded INTEGER NOT NULL DEFAULT 1 CHECK (expanded IN (0, 1)),
566
778
 
779
+ CHECK ((op IS NULL) = COALESCE(json_extract(attrs, '$.kind') IN ('initialization', 'model_emission'), 0)),
780
+ CHECK (json_extract(attrs, '$.kind') != 'initialization' OR origin = 'plurnk'),
781
+ CHECK (json_extract(attrs, '$.kind') != 'model_emission' OR origin = 'model'),
782
+
567
783
  FOREIGN KEY (worker_id) REFERENCES workers(id) ON DELETE CASCADE,
568
784
  FOREIGN KEY (loop_id) REFERENCES loops(id) ON DELETE CASCADE,
569
785
  FOREIGN KEY (turn_id) REFERENCES turns(id) ON DELETE CASCADE
@@ -573,37 +789,388 @@ CREATE UNIQUE INDEX IF NOT EXISTS log_entries_turn_id_sequence ON log_entries (t
573
789
  CREATE INDEX IF NOT EXISTS log_entries_worker_id ON log_entries (worker_id);
574
790
  CREATE INDEX IF NOT EXISTS log_entries_loop_id ON log_entries (loop_id);
575
791
  CREATE INDEX IF NOT EXISTS log_entries_at ON log_entries (at);
792
+ CREATE UNIQUE INDEX IF NOT EXISTS log_entries_model_call_id
793
+ ON log_entries (model_call_id)
794
+ WHERE model_call_id IS NOT NULL;
576
795
  CREATE UNIQUE INDEX IF NOT EXISTS log_entries_worker_ambient_event
577
796
  ON log_entries (worker_id, ambient_event_id)
578
797
  WHERE ambient_event_id IS NOT NULL;
579
798
 
580
- -- {§log-region-tagging} — named log-region curation. FOLD is the log's write-op (EDIT can't
581
- -- reach engine-written rows): FOLD[tag] stamps a tag on a region; OPEN[tag]/FIND[tag] filter
582
- -- by it. Mirrors entry_tags (apply additive / filter ALL-tags AND); CASCADE with the row on KILL.
799
+ CREATE TRIGGER IF NOT EXISTS log_entries_model_call_valid
800
+ BEFORE INSERT ON log_entries
801
+ WHEN NEW.model_call_id IS NOT NULL
802
+ AND NOT EXISTS (
803
+ SELECT 1
804
+ FROM model_calls call
805
+ WHERE call.id = NEW.model_call_id
806
+ AND call.turn_id = NEW.turn_id
807
+ AND call.state != 'pending'
808
+ AND (
809
+ (call.kind = 'bare' AND NEW.op = 'BARE')
810
+ OR (
811
+ call.kind = 'emission'
812
+ AND NEW.op IS NULL
813
+ AND json_extract(NEW.attrs, '$.kind') = 'model_emission'
814
+ )
815
+ )
816
+ )
817
+ BEGIN
818
+ SELECT RAISE(ABORT, 'log entry model call does not match its represented result');
819
+ END;
820
+
821
+ -- {§log-item-tags} — log classification plus OPEN/FOLD selection and mutation.
822
+ -- CASCADE erases the classification with its row.
583
823
  CREATE TABLE IF NOT EXISTS log_tags (
584
824
  log_entry_id INTEGER NOT NULL,
585
- tag TEXT NOT NULL CHECK (length(tag) > 0),
825
+ tag TEXT NOT NULL,
586
826
  PRIMARY KEY (log_entry_id, tag),
587
827
  FOREIGN KEY (log_entry_id) REFERENCES log_entries(id) ON DELETE CASCADE
588
828
  ) STRICT, WITHOUT ROWID;
589
829
 
830
+ -- The relational table owns stored tag identity. Producers and curation
831
+ -- signals carry signs, but the stored name never does; delimiters, whitespace,
832
+ -- and control characters cannot become durable classifications through a
833
+ -- private SQL caller either.
834
+ CREATE TRIGGER IF NOT EXISTS log_tags_name_valid
835
+ BEFORE INSERT ON log_tags
836
+ WHEN length(NEW.tag) = 0
837
+ OR substr(NEW.tag, 1, 1) IN ('+', '-')
838
+ OR instr(NEW.tag, '[') > 0
839
+ OR instr(NEW.tag, ']') > 0
840
+ OR instr(NEW.tag, ',') > 0
841
+ OR instr(NEW.tag, char(0)) > 0
842
+ OR EXISTS (
843
+ WITH RECURSIVE offsets(position) AS (
844
+ SELECT 1
845
+ UNION ALL
846
+ SELECT position + 1 FROM offsets WHERE position < length(NEW.tag)
847
+ )
848
+ SELECT 1
849
+ FROM offsets
850
+ WHERE unicode(substr(NEW.tag, position, 1)) BETWEEN 0 AND 32
851
+ OR unicode(substr(NEW.tag, position, 1)) BETWEEN 127 AND 159
852
+ OR unicode(substr(NEW.tag, position, 1)) IN (160, 5760, 8232, 8233, 8239, 8287, 12288, 65279)
853
+ OR unicode(substr(NEW.tag, position, 1)) BETWEEN 8192 AND 8202
854
+ )
855
+ BEGIN
856
+ SELECT RAISE(ABORT, 'log tag name is invalid');
857
+ END;
858
+
590
859
  CREATE INDEX IF NOT EXISTS log_tags_tag ON log_tags (tag);
591
860
 
592
- -- Column-scoped immutability: the original action's identity and target
593
- -- never change; the proposal lifecycle is allowed to mutate state,
594
- -- outcome, status_rx, rx, expanded.
861
+ -- Signal-derived classification is part of the log-row insert, not a later JS
862
+ -- write. This keeps the operation, its ambient occurrence, and its complete
863
+ -- initial folksonomy inside one SQLite commit.
864
+ CREATE TRIGGER IF NOT EXISTS log_entries_classify_signal
865
+ AFTER INSERT ON log_entries
866
+ WHEN NEW.op IN ('FIND', 'READ', 'EDIT', 'COPY', 'MOVE', 'BARE')
867
+ AND NEW.signal IS NOT NULL
868
+ BEGIN
869
+ SELECT CASE
870
+ WHEN json_type(NEW.signal) != 'array'
871
+ OR EXISTS (
872
+ SELECT 1 FROM json_each(NEW.signal)
873
+ WHERE type != 'text'
874
+ OR length(value) = 0
875
+ OR substr(value, 1, 1) = '-'
876
+ OR (
877
+ substr(value, 1, 1) = '+'
878
+ AND (length(value) < 2 OR substr(value, 2, 1) IN ('+', '-'))
879
+ )
880
+ OR instr(value, '[') > 0
881
+ OR instr(value, ']') > 0
882
+ OR instr(value, ',') > 0
883
+ OR instr(value, char(0)) > 0
884
+ OR EXISTS (
885
+ WITH RECURSIVE offsets(position) AS (
886
+ SELECT 1
887
+ UNION ALL
888
+ SELECT position + 1 FROM offsets WHERE position < length(value)
889
+ )
890
+ SELECT 1
891
+ FROM offsets
892
+ WHERE unicode(substr(value, position, 1)) BETWEEN 0 AND 32
893
+ OR unicode(substr(value, position, 1)) BETWEEN 127 AND 159
894
+ OR unicode(substr(value, position, 1)) IN (160, 5760, 8232, 8233, 8239, 8287, 12288, 65279)
895
+ OR unicode(substr(value, position, 1)) BETWEEN 8192 AND 8202
896
+ )
897
+ )
898
+ THEN RAISE(ABORT, 'classifying log operation signal accepts only tag or +tag additions')
899
+ END;
900
+ INSERT OR IGNORE INTO log_tags (log_entry_id, tag)
901
+ SELECT NEW.id, CASE WHEN substr(value, 1, 1) = '+' THEN substr(value, 2) ELSE value END
902
+ FROM json_each(NEW.signal);
903
+
904
+ -- The ambient-event and classification AFTER INSERT triggers may run in
905
+ -- either order. If the event already exists, finish its initial
906
+ -- snapshot here; otherwise its own trigger aggregates these rows later.
907
+ UPDATE ambient_events
908
+ SET tags = COALESCE((
909
+ SELECT json_group_array(tag)
910
+ FROM (SELECT tag FROM log_tags WHERE log_entry_id = NEW.id ORDER BY tag)
911
+ ), '[]')
912
+ WHERE kind = 'edit'
913
+ AND producer_worker_id = NEW.worker_id
914
+ AND source_record_id = NEW.id;
915
+ END;
916
+
917
+ -- Successful OPEN/FOLD rows are durable curation events even though their
918
+ -- ordinary packet projection is suppressed ({§fold-open-meta-operations}).
919
+ -- Preserve the exact selected set and each target's pre-event visibility so a
920
+ -- broad selector never collapses into the lossy fact `matched: N`.
921
+ CREATE TABLE IF NOT EXISTS log_curation_effects (
922
+ operation_log_entry_id INTEGER NOT NULL,
923
+ target_log_entry_id INTEGER NOT NULL,
924
+ expanded_before INTEGER NOT NULL CHECK (expanded_before IN (0, 1)),
925
+ tags_added TEXT NOT NULL DEFAULT '[]'
926
+ CHECK (json_valid(tags_added) AND json_type(tags_added) = 'array'),
927
+ tags_removed TEXT NOT NULL DEFAULT '[]'
928
+ CHECK (json_valid(tags_removed) AND json_type(tags_removed) = 'array'),
929
+ PRIMARY KEY (operation_log_entry_id, target_log_entry_id),
930
+ CHECK (operation_log_entry_id != target_log_entry_id),
931
+ FOREIGN KEY (operation_log_entry_id) REFERENCES log_entries(id) ON DELETE CASCADE,
932
+ FOREIGN KEY (target_log_entry_id) REFERENCES log_entries(id) ON DELETE CASCADE
933
+ ) STRICT, WITHOUT ROWID;
934
+
935
+ CREATE INDEX IF NOT EXISTS log_curation_effects_target
936
+ ON log_curation_effects (target_log_entry_id);
937
+
938
+ -- Make an invalid curation record structurally unavailable: the event must be
939
+ -- one successful OPEN/FOLD row, both rows must belong to the same worker, and
940
+ -- both exact delta sets must contain only unique canonical tag identities.
941
+ CREATE TRIGGER IF NOT EXISTS log_curation_effects_valid
942
+ BEFORE INSERT ON log_curation_effects
943
+ BEGIN
944
+ SELECT CASE WHEN
945
+ NOT EXISTS (
946
+ SELECT 1
947
+ FROM log_entries operation
948
+ JOIN log_entries target ON target.id = NEW.target_log_entry_id
949
+ WHERE operation.id = NEW.operation_log_entry_id
950
+ AND operation.op IN ('OPEN', 'FOLD')
951
+ AND operation.status_rx < 400
952
+ AND operation.worker_id = target.worker_id
953
+ )
954
+ OR EXISTS (
955
+ SELECT 1
956
+ FROM (
957
+ SELECT value, type FROM json_each(NEW.tags_added)
958
+ UNION ALL
959
+ SELECT value, type FROM json_each(NEW.tags_removed)
960
+ ) tag
961
+ WHERE tag.type != 'text'
962
+ OR length(tag.value) = 0
963
+ OR substr(tag.value, 1, 1) IN ('+', '-')
964
+ OR instr(tag.value, '[') > 0
965
+ OR instr(tag.value, ']') > 0
966
+ OR instr(tag.value, ',') > 0
967
+ OR instr(tag.value, char(0)) > 0
968
+ OR EXISTS (
969
+ WITH RECURSIVE offsets(position) AS (
970
+ SELECT 1
971
+ UNION ALL
972
+ SELECT position + 1 FROM offsets WHERE position < length(tag.value)
973
+ )
974
+ SELECT 1
975
+ FROM offsets
976
+ WHERE unicode(substr(tag.value, position, 1)) BETWEEN 0 AND 32
977
+ OR unicode(substr(tag.value, position, 1)) BETWEEN 127 AND 159
978
+ OR unicode(substr(tag.value, position, 1)) IN (160, 5760, 8232, 8233, 8239, 8287, 12288, 65279)
979
+ OR unicode(substr(tag.value, position, 1)) BETWEEN 8192 AND 8202
980
+ )
981
+ )
982
+ OR (SELECT COUNT(*) FROM json_each(NEW.tags_added))
983
+ != (SELECT COUNT(DISTINCT value) FROM json_each(NEW.tags_added))
984
+ OR (SELECT COUNT(*) FROM json_each(NEW.tags_removed))
985
+ != (SELECT COUNT(DISTINCT value) FROM json_each(NEW.tags_removed))
986
+ OR EXISTS (
987
+ SELECT 1
988
+ FROM json_each(NEW.tags_added) addition
989
+ JOIN json_each(NEW.tags_removed) removal ON removal.value = addition.value
990
+ )
991
+ THEN RAISE(ABORT, 'invalid log curation effect') END;
992
+ END;
993
+
994
+ -- The dispatcher binds an exact, transient curation plan into attrs on the
995
+ -- successful OPEN/FOLD row. No other row may carry that private payload.
996
+ CREATE TRIGGER IF NOT EXISTS log_entries_curation_payload_valid
997
+ BEFORE INSERT ON log_entries
998
+ WHEN json_type(NEW.attrs, '$.__plurnk_curation') IS NOT NULL
999
+ AND NOT (
1000
+ NEW.op IN ('OPEN', 'FOLD')
1001
+ AND NEW.status_rx < 400
1002
+ AND json_type(NEW.attrs, '$.__plurnk_curation') = 'object'
1003
+ )
1004
+ BEGIN
1005
+ SELECT RAISE(ABORT, 'private log curation payload requires a successful OPEN/FOLD row');
1006
+ END;
1007
+
1008
+ -- One outer INSERT owns the whole landed curation event: exact selected rows,
1009
+ -- their pre-event visibility and tag deltas, the requested visibility, and the
1010
+ -- resulting classifications. Trigger failure rolls the operation row and all
1011
+ -- effects back together. The private plan is erased before INSERT returns.
1012
+ CREATE TRIGGER IF NOT EXISTS log_entries_apply_curation
1013
+ AFTER INSERT ON log_entries
1014
+ WHEN NEW.op IN ('OPEN', 'FOLD')
1015
+ AND NEW.status_rx < 400
1016
+ AND json_type(NEW.attrs, '$.__plurnk_curation') = 'object'
1017
+ BEGIN
1018
+ SELECT CASE WHEN
1019
+ COALESCE(json_type(NEW.attrs, '$.__plurnk_curation.ids'), '') != 'array'
1020
+ OR COALESCE(json_type(NEW.attrs, '$.__plurnk_curation.add'), '') != 'array'
1021
+ OR COALESCE(json_type(NEW.attrs, '$.__plurnk_curation.remove'), '') != 'array'
1022
+ OR COALESCE(json_type(NEW.attrs, '$.__plurnk_curation.expanded'), '') != 'integer'
1023
+ OR json_extract(NEW.attrs, '$.__plurnk_curation.expanded') NOT IN (0, 1)
1024
+ OR json_array_length(NEW.attrs, '$.__plurnk_curation.ids') = 0
1025
+ OR EXISTS (
1026
+ SELECT 1 FROM json_each(NEW.attrs, '$.__plurnk_curation.ids')
1027
+ WHERE type != 'integer' OR value <= 0
1028
+ )
1029
+ OR (
1030
+ SELECT COUNT(*) FROM json_each(NEW.attrs, '$.__plurnk_curation.ids')
1031
+ ) != (
1032
+ SELECT COUNT(DISTINCT value) FROM json_each(NEW.attrs, '$.__plurnk_curation.ids')
1033
+ )
1034
+ OR EXISTS (
1035
+ SELECT 1
1036
+ FROM json_each(NEW.attrs, '$.__plurnk_curation.ids') selected
1037
+ LEFT JOIN log_entries target ON target.id = selected.value
1038
+ WHERE target.id IS NULL OR target.worker_id != NEW.worker_id OR target.id = NEW.id
1039
+ )
1040
+ OR EXISTS (
1041
+ SELECT 1
1042
+ FROM (
1043
+ SELECT value, type FROM json_each(NEW.attrs, '$.__plurnk_curation.add')
1044
+ UNION ALL
1045
+ SELECT value, type FROM json_each(NEW.attrs, '$.__plurnk_curation.remove')
1046
+ ) tag
1047
+ WHERE tag.type != 'text'
1048
+ OR length(tag.value) = 0
1049
+ OR substr(tag.value, 1, 1) IN ('+', '-')
1050
+ OR instr(tag.value, '[') > 0
1051
+ OR instr(tag.value, ']') > 0
1052
+ OR instr(tag.value, ',') > 0
1053
+ OR instr(tag.value, char(0)) > 0
1054
+ OR EXISTS (
1055
+ WITH RECURSIVE offsets(position) AS (
1056
+ SELECT 1
1057
+ UNION ALL
1058
+ SELECT position + 1 FROM offsets WHERE position < length(tag.value)
1059
+ )
1060
+ SELECT 1
1061
+ FROM offsets
1062
+ WHERE unicode(substr(tag.value, position, 1)) BETWEEN 0 AND 32
1063
+ OR unicode(substr(tag.value, position, 1)) BETWEEN 127 AND 159
1064
+ OR unicode(substr(tag.value, position, 1)) IN (160, 5760, 8232, 8233, 8239, 8287, 12288, 65279)
1065
+ OR unicode(substr(tag.value, position, 1)) BETWEEN 8192 AND 8202
1066
+ )
1067
+ )
1068
+ OR (
1069
+ SELECT COUNT(*) FROM json_each(NEW.attrs, '$.__plurnk_curation.add')
1070
+ ) != (
1071
+ SELECT COUNT(DISTINCT value) FROM json_each(NEW.attrs, '$.__plurnk_curation.add')
1072
+ )
1073
+ OR (
1074
+ SELECT COUNT(*) FROM json_each(NEW.attrs, '$.__plurnk_curation.remove')
1075
+ ) != (
1076
+ SELECT COUNT(DISTINCT value) FROM json_each(NEW.attrs, '$.__plurnk_curation.remove')
1077
+ )
1078
+ OR EXISTS (
1079
+ SELECT 1
1080
+ FROM json_each(NEW.attrs, '$.__plurnk_curation.add') addition
1081
+ JOIN json_each(NEW.attrs, '$.__plurnk_curation.remove') removal
1082
+ ON removal.value = addition.value
1083
+ )
1084
+ THEN RAISE(ABORT, 'invalid private log curation payload') END;
1085
+
1086
+ INSERT INTO log_curation_effects (
1087
+ operation_log_entry_id,
1088
+ target_log_entry_id,
1089
+ expanded_before,
1090
+ tags_added,
1091
+ tags_removed
1092
+ )
1093
+ SELECT
1094
+ NEW.id,
1095
+ target.id,
1096
+ target.expanded,
1097
+ COALESCE((
1098
+ SELECT json_group_array(ordered.tag)
1099
+ FROM (
1100
+ SELECT addition.value AS tag
1101
+ FROM json_each(NEW.attrs, '$.__plurnk_curation.add') addition
1102
+ WHERE NOT EXISTS (
1103
+ SELECT 1 FROM log_tags
1104
+ WHERE log_entry_id = target.id AND tag = addition.value
1105
+ )
1106
+ ORDER BY addition.value
1107
+ ) ordered
1108
+ ), '[]'),
1109
+ COALESCE((
1110
+ SELECT json_group_array(ordered.tag)
1111
+ FROM (
1112
+ SELECT removal.value AS tag
1113
+ FROM json_each(NEW.attrs, '$.__plurnk_curation.remove') removal
1114
+ WHERE EXISTS (
1115
+ SELECT 1 FROM log_tags
1116
+ WHERE log_entry_id = target.id AND tag = removal.value
1117
+ )
1118
+ ORDER BY removal.value
1119
+ ) ordered
1120
+ ), '[]')
1121
+ FROM json_each(NEW.attrs, '$.__plurnk_curation.ids') selected
1122
+ JOIN log_entries target ON target.id = selected.value;
1123
+
1124
+ UPDATE log_entries
1125
+ SET expanded = json_extract(NEW.attrs, '$.__plurnk_curation.expanded')
1126
+ WHERE id IN (
1127
+ SELECT value FROM json_each(NEW.attrs, '$.__plurnk_curation.ids')
1128
+ );
1129
+
1130
+ DELETE FROM log_tags
1131
+ WHERE log_entry_id IN (
1132
+ SELECT value FROM json_each(NEW.attrs, '$.__plurnk_curation.ids')
1133
+ )
1134
+ AND tag IN (
1135
+ SELECT value FROM json_each(NEW.attrs, '$.__plurnk_curation.remove')
1136
+ );
1137
+
1138
+ INSERT OR IGNORE INTO log_tags (log_entry_id, tag)
1139
+ SELECT selected.value, addition.value
1140
+ FROM json_each(NEW.attrs, '$.__plurnk_curation.ids') selected
1141
+ CROSS JOIN json_each(NEW.attrs, '$.__plurnk_curation.add') addition;
1142
+
1143
+ UPDATE log_entries
1144
+ SET attrs = json_remove(attrs, '$.__plurnk_curation')
1145
+ WHERE id = NEW.id;
1146
+ END;
1147
+
1148
+ -- Column-scoped immutability: the original action's identity and target never
1149
+ -- change; the proposal lifecycle is allowed to mutate state, outcome,
1150
+ -- status_rx, rx, expanded. Keep attrs separate so the curation trigger's one
1151
+ -- private-payload removal cannot exempt changes to any other core column.
595
1152
  CREATE TRIGGER IF NOT EXISTS log_entries_immutable_core
596
1153
  BEFORE UPDATE OF
597
- worker_id, loop_id, turn_id, sequence, at, origin, source,
1154
+ worker_id, loop_id, turn_id, sequence, at, origin, source, model_call_id,
598
1155
  op, suffix, signal,
599
1156
  scheme, username, password, hostname,
600
1157
  port, pathname, query, fragment,
601
- lineMarker, tx, mimetype_tx, mimetype_rx, attrs
1158
+ lineMarker, tx, mimetype_tx, mimetype_rx
602
1159
  ON log_entries
603
1160
  BEGIN
604
1161
  SELECT RAISE(ABORT, 'log_entries core fields are immutable; only state/outcome/status_rx/rx/expanded may change');
605
1162
  END;
606
1163
 
1164
+ CREATE TRIGGER IF NOT EXISTS log_entries_immutable_attrs
1165
+ BEFORE UPDATE OF attrs ON log_entries
1166
+ WHEN COALESCE((
1167
+ json_type(OLD.attrs, '$.__plurnk_curation') = 'object'
1168
+ AND NEW.attrs = json_remove(OLD.attrs, '$.__plurnk_curation')
1169
+ ), 0) = 0
1170
+ BEGIN
1171
+ SELECT RAISE(ABORT, 'log_entries attrs are immutable outside curation payload removal');
1172
+ END;
1173
+
607
1174
  -- The engine may stamp an originating row exactly once. No later reassignment
608
1175
  -- can sever or counterfeit the occurrence identity.
609
1176
  CREATE TRIGGER IF NOT EXISTS log_entries_ambient_event_once
@@ -631,10 +1198,17 @@ WHEN NEW.ambient_event_id IS NULL
631
1198
  BEGIN
632
1199
  INSERT INTO ambient_events (
633
1200
  workspace_id, producer_worker_id, kind, source_record_id, source,
634
- op, scheme, hostname, pathname, rx, attrs, status_rx
1201
+ op, scheme, hostname, pathname, rx, attrs, tags, status_rx
635
1202
  )
636
1203
  SELECT w.workspace_id, NEW.worker_id, 'edit', NEW.id, NEW.source,
637
- 'EDIT', NEW.scheme, NEW.hostname, NEW.pathname, NEW.rx, NEW.attrs, NEW.status_rx
1204
+ 'EDIT', NEW.scheme, NEW.hostname, NEW.pathname, NEW.rx, NEW.attrs,
1205
+ COALESCE((
1206
+ SELECT json_group_array(ordered.tag)
1207
+ FROM (
1208
+ SELECT tag FROM log_tags WHERE log_entry_id = NEW.id ORDER BY tag
1209
+ ) ordered
1210
+ ), '[]'),
1211
+ NEW.status_rx
638
1212
  FROM workers w
639
1213
  WHERE w.id = NEW.worker_id;
640
1214
  UPDATE log_entries SET ambient_event_id = last_insert_rowid() WHERE id = NEW.id;
@@ -658,87 +1232,22 @@ WHEN OLD.state = 'proposed'
658
1232
  BEGIN
659
1233
  INSERT INTO ambient_events (
660
1234
  workspace_id, producer_worker_id, kind, source_record_id, source,
661
- op, scheme, hostname, pathname, rx, attrs, status_rx
1235
+ op, scheme, hostname, pathname, rx, attrs, tags, status_rx
662
1236
  )
663
1237
  SELECT w.workspace_id, NEW.worker_id, 'edit', NEW.id, NEW.source,
664
- 'EDIT', NEW.scheme, NEW.hostname, NEW.pathname, NEW.rx, NEW.attrs, NEW.status_rx
1238
+ 'EDIT', NEW.scheme, NEW.hostname, NEW.pathname, NEW.rx, NEW.attrs,
1239
+ COALESCE((
1240
+ SELECT json_group_array(ordered.tag)
1241
+ FROM (
1242
+ SELECT tag FROM log_tags WHERE log_entry_id = NEW.id ORDER BY tag
1243
+ ) ordered
1244
+ ), '[]'),
1245
+ NEW.status_rx
665
1246
  FROM workers w
666
1247
  WHERE w.id = NEW.worker_id;
667
1248
  UPDATE log_entries SET ambient_event_id = last_insert_rowid() WHERE id = NEW.id;
668
1249
  END;
669
1250
 
670
- -- cost_rollups
671
- -- Triggers maintaining denormalized USD totals on workers and workspaces
672
- -- as turns insert/update. Pure denormalization (textbook trigger use);
673
- -- no branching state-machine logic lives here.
674
- CREATE TRIGGER IF NOT EXISTS turns_cost_rollup_insert_worker
675
- AFTER INSERT ON turns
676
- BEGIN
677
- UPDATE workers
678
- SET cost_usd = (
679
- SELECT CASE WHEN COUNT(*) FILTER (WHERE t.usage_cost_usd IS NULL) > 0
680
- THEN NULL ELSE COALESCE(SUM(t.usage_cost_usd), 0) END
681
- FROM turns t JOIN loops l ON l.id = t.loop_id
682
- WHERE l.worker_id = workers.id
683
- )
684
- WHERE id = (SELECT worker_id FROM loops WHERE id = NEW.loop_id);
685
- END;
686
-
687
- CREATE TRIGGER IF NOT EXISTS turns_cost_rollup_insert_workspace
688
- AFTER INSERT ON turns
689
- BEGIN
690
- UPDATE workspaces
691
- SET cost_usd = (
692
- SELECT CASE WHEN COUNT(*) FILTER (WHERE t.usage_cost_usd IS NULL) > 0
693
- THEN NULL ELSE COALESCE(SUM(t.usage_cost_usd), 0) END
694
- FROM turns t
695
- JOIN loops l ON l.id = t.loop_id
696
- JOIN workers r ON r.id = l.worker_id
697
- WHERE r.workspace_id = workspaces.id
698
- )
699
- WHERE id = (
700
- SELECT r.workspace_id
701
- FROM workers r
702
- JOIN loops l ON l.worker_id = r.id
703
- WHERE l.id = NEW.loop_id
704
- );
705
- END;
706
-
707
- CREATE TRIGGER IF NOT EXISTS turns_cost_rollup_update_worker
708
- AFTER UPDATE OF usage_cost_usd ON turns
709
- WHEN NEW.usage_cost_usd IS NOT OLD.usage_cost_usd
710
- BEGIN
711
- UPDATE workers
712
- SET cost_usd = (
713
- SELECT CASE WHEN COUNT(*) FILTER (WHERE t.usage_cost_usd IS NULL) > 0
714
- THEN NULL ELSE COALESCE(SUM(t.usage_cost_usd), 0) END
715
- FROM turns t JOIN loops l ON l.id = t.loop_id
716
- WHERE l.worker_id = workers.id
717
- )
718
- WHERE id = (SELECT worker_id FROM loops WHERE id = NEW.loop_id);
719
- END;
720
-
721
- CREATE TRIGGER IF NOT EXISTS turns_cost_rollup_update_workspace
722
- AFTER UPDATE OF usage_cost_usd ON turns
723
- WHEN NEW.usage_cost_usd IS NOT OLD.usage_cost_usd
724
- BEGIN
725
- UPDATE workspaces
726
- SET cost_usd = (
727
- SELECT CASE WHEN COUNT(*) FILTER (WHERE t.usage_cost_usd IS NULL) > 0
728
- THEN NULL ELSE COALESCE(SUM(t.usage_cost_usd), 0) END
729
- FROM turns t
730
- JOIN loops l ON l.id = t.loop_id
731
- JOIN workers r ON r.id = l.worker_id
732
- WHERE r.workspace_id = workspaces.id
733
- )
734
- WHERE id = (
735
- SELECT r.workspace_id
736
- FROM workers r
737
- JOIN loops l ON l.worker_id = r.id
738
- WHERE l.id = NEW.loop_id
739
- );
740
- END;
741
-
742
1251
  -- subscriptions
743
1252
  -- Durable subscription lifecycle per SPEC {§subscriptions}. The row records what
744
1253
  -- the worker holds and routes cancellation to a separate process-local callable;