@meetless/mla 0.1.4

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 (202) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +81 -0
  3. package/dist/build-info.json +9 -0
  4. package/dist/bundles/ask-core.js +396 -0
  5. package/dist/bundles/mcp.js +16592 -0
  6. package/dist/bundles/trace-core.js +263 -0
  7. package/dist/cli.js +828 -0
  8. package/dist/commands/activate.js +781 -0
  9. package/dist/commands/adoption.js +130 -0
  10. package/dist/commands/ask.js +290 -0
  11. package/dist/commands/context.js +114 -0
  12. package/dist/commands/debug.js +313 -0
  13. package/dist/commands/doctor.js +1021 -0
  14. package/dist/commands/enrich.js +427 -0
  15. package/dist/commands/evidence.js +229 -0
  16. package/dist/commands/flush.js +184 -0
  17. package/dist/commands/graph.js +104 -0
  18. package/dist/commands/init.js +272 -0
  19. package/dist/commands/internal-active-review.js +322 -0
  20. package/dist/commands/internal-auto-index.js +188 -0
  21. package/dist/commands/internal-capture-decisions.js +320 -0
  22. package/dist/commands/internal-evidence-correlate.js +239 -0
  23. package/dist/commands/internal-evidence-hooks.js +240 -0
  24. package/dist/commands/internal-evidence-inject.js +231 -0
  25. package/dist/commands/internal-finalize.js +221 -0
  26. package/dist/commands/internal-pretool-observe.js +225 -0
  27. package/dist/commands/internal-refresh.js +136 -0
  28. package/dist/commands/internal-session-nudge.js +120 -0
  29. package/dist/commands/internal-steer-sync.js +117 -0
  30. package/dist/commands/internal-turn-recap.js +140 -0
  31. package/dist/commands/kb.js +375 -0
  32. package/dist/commands/kb_add.js +681 -0
  33. package/dist/commands/kb_forget.js +283 -0
  34. package/dist/commands/kb_move.js +45 -0
  35. package/dist/commands/kb_pending.js +410 -0
  36. package/dist/commands/kb_personal.js +149 -0
  37. package/dist/commands/kb_promote.js +188 -0
  38. package/dist/commands/kb_purge.js +168 -0
  39. package/dist/commands/kb_reingest.js +335 -0
  40. package/dist/commands/kb_retime.js +170 -0
  41. package/dist/commands/kb_review.js +391 -0
  42. package/dist/commands/kb_revision.js +179 -0
  43. package/dist/commands/kb_show.js +385 -0
  44. package/dist/commands/label.js +226 -0
  45. package/dist/commands/login.js +295 -0
  46. package/dist/commands/logout.js +108 -0
  47. package/dist/commands/mcp-supervisor.js +93 -0
  48. package/dist/commands/mcp.js +227 -0
  49. package/dist/commands/queue-prune.js +98 -0
  50. package/dist/commands/review.js +358 -0
  51. package/dist/commands/rewire.js +124 -0
  52. package/dist/commands/rules.js +728 -0
  53. package/dist/commands/scan-context.js +67 -0
  54. package/dist/commands/session.js +347 -0
  55. package/dist/commands/stats.js +479 -0
  56. package/dist/commands/status.js +61 -0
  57. package/dist/commands/summary.js +250 -0
  58. package/dist/commands/turn.js +114 -0
  59. package/dist/commands/uninstall.js +222 -0
  60. package/dist/commands/whoami.js +102 -0
  61. package/dist/commands/workspace.js +130 -0
  62. package/dist/hooks-template/ce0-post-tool-use.sh +34 -0
  63. package/dist/hooks-template/ce0-session-start.sh +49 -0
  64. package/dist/hooks-template/ce0-stop.sh +29 -0
  65. package/dist/hooks-template/ce0-user-prompt-submit.sh +38 -0
  66. package/dist/hooks-template/common.sh +934 -0
  67. package/dist/hooks-template/event-batch-filter.jq +67 -0
  68. package/dist/hooks-template/flush.sh +503 -0
  69. package/dist/hooks-template/post-tool-use.sh +423 -0
  70. package/dist/hooks-template/pre-tool-use.sh +69 -0
  71. package/dist/hooks-template/session-start.sh +140 -0
  72. package/dist/hooks-template/stop.sh +308 -0
  73. package/dist/hooks-template/user-prompt-submit.sh +1162 -0
  74. package/dist/lib/activation.js +79 -0
  75. package/dist/lib/active-conflict-cache.js +141 -0
  76. package/dist/lib/active-memory.js +59 -0
  77. package/dist/lib/active-review-runner.js +26 -0
  78. package/dist/lib/agent-decision/index.js +25 -0
  79. package/dist/lib/agent-decision/keys.js +49 -0
  80. package/dist/lib/agent-decision/normalize-claude.js +183 -0
  81. package/dist/lib/agent-decision/types.js +21 -0
  82. package/dist/lib/agent-decision/validate.js +216 -0
  83. package/dist/lib/analytics/capture.js +96 -0
  84. package/dist/lib/analytics/command-event.js +267 -0
  85. package/dist/lib/analytics/consent.js +58 -0
  86. package/dist/lib/analytics/coverage-gap.js +96 -0
  87. package/dist/lib/analytics/envelope.js +236 -0
  88. package/dist/lib/analytics/event-id.js +86 -0
  89. package/dist/lib/analytics/evidence.js +150 -0
  90. package/dist/lib/analytics/followthrough.js +194 -0
  91. package/dist/lib/analytics/forwarder.js +109 -0
  92. package/dist/lib/analytics/logs.js +78 -0
  93. package/dist/lib/analytics/metrics.js +78 -0
  94. package/dist/lib/analytics/recorder.js +92 -0
  95. package/dist/lib/analytics/review-analytics.js +75 -0
  96. package/dist/lib/analytics/sequence.js +77 -0
  97. package/dist/lib/analytics/store.js +131 -0
  98. package/dist/lib/analytics/turn-recap.js +279 -0
  99. package/dist/lib/artifact_id.js +108 -0
  100. package/dist/lib/auth-breaker.js +161 -0
  101. package/dist/lib/auto-index.js +112 -0
  102. package/dist/lib/classifier.js +88 -0
  103. package/dist/lib/config.js +298 -0
  104. package/dist/lib/conflict-advisory.js +64 -0
  105. package/dist/lib/debug-bundle.js +520 -0
  106. package/dist/lib/enrichment/ingest.js +301 -0
  107. package/dist/lib/enrichment/plan.js +253 -0
  108. package/dist/lib/enrichment/protocol.js +359 -0
  109. package/dist/lib/enrichment/scout-brief.js +176 -0
  110. package/dist/lib/failure-telemetry.js +444 -0
  111. package/dist/lib/git.js +200 -0
  112. package/dist/lib/governance-cache.js +77 -0
  113. package/dist/lib/governed-path-cache.js +76 -0
  114. package/dist/lib/http.js +677 -0
  115. package/dist/lib/identity-envelope.js +23 -0
  116. package/dist/lib/kb-candidate.js +65 -0
  117. package/dist/lib/kb_acl.js +98 -0
  118. package/dist/lib/login.js +353 -0
  119. package/dist/lib/mcp-fetchers.js +130 -0
  120. package/dist/lib/mcp-restart.js +47 -0
  121. package/dist/lib/observability.js +805 -0
  122. package/dist/lib/open-url.js +33 -0
  123. package/dist/lib/orphan-guard.js +70 -0
  124. package/dist/lib/packaged.js +21 -0
  125. package/dist/lib/reconcile-sessions.js +171 -0
  126. package/dist/lib/redactor.js +89 -0
  127. package/dist/lib/relationship-candidate-query.js +27 -0
  128. package/dist/lib/render.js +611 -0
  129. package/dist/lib/rules/applicability.js +64 -0
  130. package/dist/lib/rules/attest-code-rule-version.js +47 -0
  131. package/dist/lib/rules/attest-notes-location.js +217 -0
  132. package/dist/lib/rules/attest-rule-version.js +69 -0
  133. package/dist/lib/rules/canonical-json.js +97 -0
  134. package/dist/lib/rules/ce0-emit.js +64 -0
  135. package/dist/lib/rules/ce0-evidence.js +281 -0
  136. package/dist/lib/rules/ce0-recall-sample.js +82 -0
  137. package/dist/lib/rules/ce0-rule.js +55 -0
  138. package/dist/lib/rules/ce0-sampling-bucket.js +15 -0
  139. package/dist/lib/rules/ce0-store.js +683 -0
  140. package/dist/lib/rules/ce0-telemetry-project.js +93 -0
  141. package/dist/lib/rules/ce0-telemetry.js +158 -0
  142. package/dist/lib/rules/code-rule-registry.js +17 -0
  143. package/dist/lib/rules/command-match.js +185 -0
  144. package/dist/lib/rules/consult-evidence-binding.js +27 -0
  145. package/dist/lib/rules/consultation-capture-adapter.js +193 -0
  146. package/dist/lib/rules/content-match.js +56 -0
  147. package/dist/lib/rules/deny-admission.js +99 -0
  148. package/dist/lib/rules/durable-observation.js +190 -0
  149. package/dist/lib/rules/enforce-notes-version.js +421 -0
  150. package/dist/lib/rules/evaluation-input-hash.js +126 -0
  151. package/dist/lib/rules/evaluator.js +108 -0
  152. package/dist/lib/rules/inert-rule-families.js +51 -0
  153. package/dist/lib/rules/input-authority-resolver.js +241 -0
  154. package/dist/lib/rules/interception-schema.js +170 -0
  155. package/dist/lib/rules/interception-store.js +267 -0
  156. package/dist/lib/rules/live-input-authority.js +66 -0
  157. package/dist/lib/rules/local-matcher.js +108 -0
  158. package/dist/lib/rules/local-observe.js +79 -0
  159. package/dist/lib/rules/local-rule-version-repo.js +214 -0
  160. package/dist/lib/rules/memory-requirement.js +109 -0
  161. package/dist/lib/rules/notes-observe.js +39 -0
  162. package/dist/lib/rules/notes-path.js +261 -0
  163. package/dist/lib/rules/notes-rule.js +75 -0
  164. package/dist/lib/rules/observe-adapter.js +114 -0
  165. package/dist/lib/rules/observed-rule-hash.js +119 -0
  166. package/dist/lib/rules/prompt-submit-adapter.js +132 -0
  167. package/dist/lib/rules/requirement-subject.js +240 -0
  168. package/dist/lib/rules/rule-activity.js +67 -0
  169. package/dist/lib/rules/rule-version-hash.js +151 -0
  170. package/dist/lib/rules/runtime-scope.js +55 -0
  171. package/dist/lib/rules/stop-adapter.js +116 -0
  172. package/dist/lib/rules/stop-response-snapshot.js +174 -0
  173. package/dist/lib/rules/types.js +10 -0
  174. package/dist/lib/rules/ulid.js +46 -0
  175. package/dist/lib/rules/version-evaluation.js +156 -0
  176. package/dist/lib/scanner/agent-memory.js +99 -0
  177. package/dist/lib/scanner/bootstrap-summary.js +87 -0
  178. package/dist/lib/scanner/cache.js +59 -0
  179. package/dist/lib/scanner/frontmatter.js +42 -0
  180. package/dist/lib/scanner/parse-directives.js +69 -0
  181. package/dist/lib/scanner/parse-structured.js +72 -0
  182. package/dist/lib/scanner/render.js +73 -0
  183. package/dist/lib/scanner/scan.js +132 -0
  184. package/dist/lib/scanner/score.js +38 -0
  185. package/dist/lib/scanner/scout-mission.js +126 -0
  186. package/dist/lib/scanner/types.js +7 -0
  187. package/dist/lib/session-scope.js +195 -0
  188. package/dist/lib/spool.js +355 -0
  189. package/dist/lib/staleness.js +100 -0
  190. package/dist/lib/steer-cache.js +87 -0
  191. package/dist/lib/tagged-reference.js +20 -0
  192. package/dist/lib/temporal.js +109 -0
  193. package/dist/lib/turn-recap-emit.js +67 -0
  194. package/dist/lib/unwire.js +253 -0
  195. package/dist/lib/update-check.js +469 -0
  196. package/dist/lib/update-notifier.js +217 -0
  197. package/dist/lib/upgrade-apply.js +643 -0
  198. package/dist/lib/wire.js +1087 -0
  199. package/dist/lib/workspace.js +96 -0
  200. package/dist/lib/zip.js +154 -0
  201. package/dist/pretool-entry.js +37 -0
  202. package/package.json +75 -0
@@ -0,0 +1,683 @@
1
+ "use strict";
2
+ // The CE0 durable store: the local SQLite WAL database that backs the evidence-
3
+ // consultation forcing function's three records
4
+ // (notes/20260617-evidence-consultation-forcing-function-proposal.md Part VII,
5
+ // P0.1-P0.6). It is the single CE0 runtime authority for "did this turn's
6
+ // obligation get satisfied?", read with one local SQLite lookup off the PreToolUse
7
+ // hot path. The doc mandates SQLite (with WAL) precisely so the obligation lifecycle
8
+ // can be a transactional read/write rather than an append-log scan.
9
+ //
10
+ // SCOPE (Commit 4): the three record TABLES and their typed insert/read only. The
11
+ // LocalTurnIdentity PARENT table and its BEGIN IMMEDIATE sequence allocation are the
12
+ // next slice; deny, recovery, correction, checkpoint, and any rollout-mode column are
13
+ // held seams and are deliberately absent. The offline per-subject CoverageAuditLabel
14
+ // is a JSONL artifact (the `mla evidence` workflow), never a table here.
15
+ //
16
+ // The CLI intentionally does not depend on @meetless/utils (see kb-candidate.ts), so
17
+ // the value shapes below are vendored with field names byte-identical to the utils CE0
18
+ // types; the array fields persist as JSON text columns and round-trip across that
19
+ // boundary without a translation layer.
20
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ var desc = Object.getOwnPropertyDescriptor(m, k);
23
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
24
+ desc = { enumerable: true, get: function() { return m[k]; } };
25
+ }
26
+ Object.defineProperty(o, k2, desc);
27
+ }) : (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ o[k2] = m[k];
30
+ }));
31
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
32
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
33
+ }) : function(o, v) {
34
+ o["default"] = v;
35
+ });
36
+ var __importStar = (this && this.__importStar) || (function () {
37
+ var ownKeys = function(o) {
38
+ ownKeys = Object.getOwnPropertyNames || function (o) {
39
+ var ar = [];
40
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
41
+ return ar;
42
+ };
43
+ return ownKeys(o);
44
+ };
45
+ return function (mod) {
46
+ if (mod && mod.__esModule) return mod;
47
+ var result = {};
48
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
49
+ __setModuleDefault(result, mod);
50
+ return result;
51
+ };
52
+ })();
53
+ var __importDefault = (this && this.__importDefault) || function (mod) {
54
+ return (mod && mod.__esModule) ? mod : { "default": mod };
55
+ };
56
+ Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.Ce0StoreSchemaVersionError = exports.CE0_SCHEMA_VERSION = void 0;
58
+ exports.defaultCe0StorePath = defaultCe0StorePath;
59
+ exports.consultationRecordToReducerInput = consultationRecordToReducerInput;
60
+ exports.openCe0Store = openCe0Store;
61
+ exports.closeCe0Store = closeCe0Store;
62
+ exports.insertTurnMemoryAssessment = insertTurnMemoryAssessment;
63
+ exports.getTurnMemoryAssessment = getTurnMemoryAssessment;
64
+ exports.listTurnMemoryAssessments = listTurnMemoryAssessments;
65
+ exports.allocateTurnIdentity = allocateTurnIdentity;
66
+ exports.openTurnAtomically = openTurnAtomically;
67
+ exports.resolveLatestTurnIdentity = resolveLatestTurnIdentity;
68
+ exports.insertTurnRuleObligation = insertTurnRuleObligation;
69
+ exports.getTurnRuleObligation = getTurnRuleObligation;
70
+ exports.listDeadlineClaimedObligations = listDeadlineClaimedObligations;
71
+ exports.insertConsultationAttempt = insertConsultationAttempt;
72
+ exports.appendConsultationAttempt = appendConsultationAttempt;
73
+ exports.getConsultationAttempt = getConsultationAttempt;
74
+ exports.listConsultationsForTurn = listConsultationsForTurn;
75
+ exports.claimFirstStop = claimFirstStop;
76
+ exports.recordStopResponseSnapshot = recordStopResponseSnapshot;
77
+ exports.finalizeObligation = finalizeObligation;
78
+ const path = __importStar(require("path"));
79
+ const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
80
+ const config_1 = require("../config");
81
+ const ce0_sampling_bucket_1 = require("./ce0-sampling-bucket");
82
+ const interception_schema_1 = require("./interception-schema");
83
+ /** The CE0 local store path: one SQLite file per machine under the Meetless home (the
84
+ * obligation rows carry their own workspace_id, and every read/finalize filters by the
85
+ * resolved workspace). It lives HERE, on the lean store module, rather than on the heavy
86
+ * `mla evidence` command module, so the PreToolUse deny hot path can resolve the store
87
+ * path without pulling evidence.ts's analytics/observability graph (latency lever A,
88
+ * notes/20260615-...-consolidated-proposal.md). The `mla evidence` command re-exports it
89
+ * for backward compatibility. */
90
+ function defaultCe0StorePath() {
91
+ return path.join(config_1.HOME, "ce0", "evidence.db");
92
+ }
93
+ /** Project a persisted consultation record onto the deterministic reducer's read subset. The
94
+ * reducer (requirement-subject) treats `result` as optional and never reads `source`, so the
95
+ * stored null becomes undefined and source is dropped. This is the SINGLE home of the
96
+ * record -> reducer-input mapping: both the offline export (`ce0-evidence`) and the finalization
97
+ * projector (`ce0-telemetry-project`) recompute the proof set over the same shape, so neither
98
+ * may carry its own copy and drift. */
99
+ function consultationRecordToReducerInput(rec) {
100
+ return {
101
+ consultationId: rec.consultationId,
102
+ consultationSubjects: rec.consultationSubjects,
103
+ execution: rec.execution,
104
+ result: rec.result ?? undefined,
105
+ deliveredToAnsweringContext: rec.deliveredToAnsweringContext,
106
+ orderingToken: rec.orderingToken,
107
+ };
108
+ }
109
+ // ---------------------------------------------------------------------------
110
+ // Schema. Exactly three tables. Array fields are JSON text; booleans are 0/1
111
+ // integers; the obligation is unique per (turn, rule version) per the doc.
112
+ // ---------------------------------------------------------------------------
113
+ const SCHEMA = `
114
+ CREATE TABLE IF NOT EXISTS turn_memory_assessment (
115
+ assessment_id TEXT PRIMARY KEY,
116
+ workspace_id TEXT NOT NULL,
117
+ session_id TEXT NOT NULL,
118
+ local_turn_sequence INTEGER NOT NULL,
119
+ requirement TEXT NOT NULL,
120
+ markers_matched TEXT NOT NULL,
121
+ exclusions_matched TEXT NOT NULL,
122
+ classifier_version TEXT NOT NULL,
123
+ marker_set_version TEXT NOT NULL,
124
+ exclusion_set_version TEXT NOT NULL,
125
+ created_at INTEGER NOT NULL,
126
+ sampling_bucket TEXT NOT NULL,
127
+ prompt_hash TEXT NOT NULL,
128
+ -- §2.3 asserted-answer half of the recall snapshot, all nullable: filled by a later UPDATE
129
+ -- (Stage A stamps stop_observed_at; best-effort Stage B fills response_hash + response_source_ref),
130
+ -- absent at the UserPromptSubmit insert and forever when Stage B cannot label the answer.
131
+ stop_observed_at INTEGER,
132
+ response_hash TEXT,
133
+ response_source_ref TEXT,
134
+ UNIQUE (workspace_id, session_id, local_turn_sequence)
135
+ );
136
+
137
+ CREATE TABLE IF NOT EXISTS turn_rule_obligation (
138
+ obligation_id TEXT PRIMARY KEY,
139
+ workspace_id TEXT NOT NULL,
140
+ session_id TEXT NOT NULL,
141
+ local_turn_sequence INTEGER NOT NULL,
142
+ rule_id TEXT NOT NULL,
143
+ rule_version_id TEXT NOT NULL,
144
+ required_subjects TEXT NOT NULL,
145
+ subject_satisfaction TEXT NOT NULL,
146
+ status TEXT NOT NULL,
147
+ state_version INTEGER NOT NULL,
148
+ deadline_claimed_at INTEGER,
149
+ deadline_claimed_version INTEGER,
150
+ response_hash TEXT,
151
+ outcome TEXT,
152
+ canonical_payload_hash TEXT NOT NULL,
153
+ UNIQUE (workspace_id, session_id, local_turn_sequence, rule_version_id),
154
+ -- §2.3: a finalized obligation carries exactly one terminal outcome, and only a finalized
155
+ -- one does. status = FINALIZED IFF outcome IS NOT NULL, enforced at the DB so no slice can
156
+ -- leave a half-finalized row (FINALIZED with no outcome, or an outcome on a live status).
157
+ CHECK ((status = 'FINALIZED') = (outcome IS NOT NULL))
158
+ );
159
+
160
+ CREATE TABLE IF NOT EXISTS consultation_attempt (
161
+ consultation_id TEXT PRIMARY KEY,
162
+ workspace_id TEXT NOT NULL,
163
+ session_id TEXT NOT NULL,
164
+ local_turn_sequence INTEGER NOT NULL,
165
+ source TEXT NOT NULL,
166
+ consultation_subjects TEXT NOT NULL,
167
+ execution TEXT NOT NULL,
168
+ result TEXT,
169
+ delivered_to_answering_context INTEGER NOT NULL,
170
+ ordering_token INTEGER NOT NULL,
171
+ created_at INTEGER NOT NULL
172
+ );
173
+ `;
174
+ /** The schema generation this code writes. Bump this whenever SCHEMA or INTERCEPTION_SCHEMA changes
175
+ * shape (a column added, dropped, or retyped). A store stamped with an older generation cannot be
176
+ * written by this code, so the opener refuses it instead of silently tolerating the drift. Because
177
+ * CE0 is an unshipped local harness ("local unshipped schema may be changed directly"), the resolution
178
+ * is a deliberate operator rebuild of the dev store, never an in-code migration / compatibility path. */
179
+ exports.CE0_SCHEMA_VERSION = 1;
180
+ /** Raised when `openCe0Store` is handed a populated store whose stamped schema generation is not the
181
+ * one this code writes. The opener refuses it loudly (and non-destructively) rather than returning a
182
+ * store whose writes would fail silently inside a fail-soft hook, the exact failure that let the live
183
+ * dogfood store drop 100% of its consultation captures undetected. */
184
+ class Ce0StoreSchemaVersionError extends Error {
185
+ dbPath;
186
+ found;
187
+ expected;
188
+ constructor(dbPath, found, expected) {
189
+ super(`CE0 store at ${dbPath} is schema version ${found}, but this mla writes version ${expected}. ` +
190
+ `The store predates a schema change and its writes would fail silently; rebuild it ` +
191
+ `(delete the file and let it recreate, or restore from a current build).`);
192
+ this.dbPath = dbPath;
193
+ this.found = found;
194
+ this.expected = expected;
195
+ this.name = "Ce0StoreSchemaVersionError";
196
+ }
197
+ }
198
+ exports.Ce0StoreSchemaVersionError = Ce0StoreSchemaVersionError;
199
+ /** Open (creating if needed) the CE0 store at `dbPath`, in WAL mode, with the schema
200
+ * applied. WAL keeps the PreToolUse reader off the writer's lock; a bounded busy_timeout
201
+ * keeps the hook from ever blocking behind a concurrent writer. The single bootstrap
202
+ * applies both the CE0 forcing-function schema and the rules interception schema
203
+ * (one database, one opener, no second migration framework).
204
+ *
205
+ * Before applying the schema, the opener reconciles the store's stamped generation: a brand-new
206
+ * (table-less) database is created at the current version, a store already at the current version is
207
+ * accepted, and a populated store at any OTHER version is REFUSED (it predates a schema change and the
208
+ * CREATE TABLE IF NOT EXISTS bootstrap cannot reshape its existing tables). Refusing loudly here turns a
209
+ * silent, fail-soft-swallowed write failure into a detectable one. */
210
+ function openCe0Store(dbPath) {
211
+ const db = new better_sqlite3_1.default(dbPath);
212
+ db.pragma("journal_mode = WAL");
213
+ db.pragma("foreign_keys = ON");
214
+ db.pragma("busy_timeout = 50");
215
+ const found = db.pragma("user_version", { simple: true });
216
+ const tableCount = db
217
+ .prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
218
+ .get().n;
219
+ // A populated store carries its own generation; if it is not the one we write, its tables cannot be
220
+ // reshaped by CREATE TABLE IF NOT EXISTS and its writes would fail. A fresh (table-less) store has no
221
+ // generation yet and is simply stamped below.
222
+ if (tableCount > 0 && found !== exports.CE0_SCHEMA_VERSION) {
223
+ db.close();
224
+ throw new Ce0StoreSchemaVersionError(dbPath, found, exports.CE0_SCHEMA_VERSION);
225
+ }
226
+ db.exec(SCHEMA);
227
+ db.exec(interception_schema_1.INTERCEPTION_SCHEMA);
228
+ db.pragma(`user_version = ${exports.CE0_SCHEMA_VERSION}`);
229
+ return { db };
230
+ }
231
+ function closeCe0Store(store) {
232
+ store.db.close();
233
+ }
234
+ // ---------------------------------------------------------------------------
235
+ // turn_memory_assessment
236
+ // ---------------------------------------------------------------------------
237
+ function insertTurnMemoryAssessment(store, rec) {
238
+ store.db
239
+ .prepare(`INSERT INTO turn_memory_assessment
240
+ (assessment_id, workspace_id, session_id, local_turn_sequence, requirement,
241
+ markers_matched, exclusions_matched, classifier_version, marker_set_version,
242
+ exclusion_set_version, created_at, sampling_bucket, prompt_hash,
243
+ stop_observed_at, response_hash, response_source_ref)
244
+ VALUES
245
+ (@assessment_id, @workspace_id, @session_id, @local_turn_sequence, @requirement,
246
+ @markers_matched, @exclusions_matched, @classifier_version, @marker_set_version,
247
+ @exclusion_set_version, @created_at, @sampling_bucket, @prompt_hash,
248
+ @stop_observed_at, @response_hash, @response_source_ref)`)
249
+ .run({
250
+ assessment_id: rec.assessmentId,
251
+ workspace_id: rec.workspaceId,
252
+ session_id: rec.sessionId,
253
+ local_turn_sequence: rec.localTurnSequence,
254
+ requirement: rec.requirement,
255
+ markers_matched: JSON.stringify(rec.markersMatched),
256
+ exclusions_matched: JSON.stringify(rec.exclusionsMatched),
257
+ classifier_version: rec.classifierVersion,
258
+ marker_set_version: rec.markerSetVersion,
259
+ exclusion_set_version: rec.exclusionSetVersion,
260
+ created_at: rec.createdAt,
261
+ sampling_bucket: rec.samplingBucket,
262
+ prompt_hash: rec.promptHash,
263
+ stop_observed_at: rec.stopObservedAt ?? null,
264
+ response_hash: rec.responseHash ?? null,
265
+ response_source_ref: rec.responseSourceRef ? JSON.stringify(rec.responseSourceRef) : null,
266
+ });
267
+ }
268
+ function mapAssessmentRow(row) {
269
+ const rec = {
270
+ assessmentId: row.assessment_id,
271
+ workspaceId: row.workspace_id,
272
+ sessionId: row.session_id,
273
+ localTurnSequence: row.local_turn_sequence,
274
+ requirement: row.requirement,
275
+ markersMatched: JSON.parse(row.markers_matched),
276
+ exclusionsMatched: JSON.parse(row.exclusions_matched),
277
+ classifierVersion: row.classifier_version,
278
+ markerSetVersion: row.marker_set_version,
279
+ exclusionSetVersion: row.exclusion_set_version,
280
+ createdAt: row.created_at,
281
+ samplingBucket: row.sampling_bucket,
282
+ promptHash: row.prompt_hash,
283
+ };
284
+ // §2.3 snapshot fields are OPTIONAL: a NULL column maps to an ABSENT key (not an `undefined` value),
285
+ // so a never-snapshotted assessment deep-equals the UserPromptSubmit insert shape that omitted them.
286
+ if (row.stop_observed_at != null)
287
+ rec.stopObservedAt = row.stop_observed_at;
288
+ if (row.response_hash != null)
289
+ rec.responseHash = row.response_hash;
290
+ if (row.response_source_ref != null) {
291
+ rec.responseSourceRef = JSON.parse(row.response_source_ref);
292
+ }
293
+ return rec;
294
+ }
295
+ function getTurnMemoryAssessment(store, assessmentId) {
296
+ const row = store.db
297
+ .prepare(`SELECT * FROM turn_memory_assessment WHERE assessment_id = ?`)
298
+ .get(assessmentId);
299
+ if (!row)
300
+ return null;
301
+ return mapAssessmentRow(row);
302
+ }
303
+ /** List every memory assessment in the workspace. The offline telemetry sweep
304
+ * (`mla evidence ce0-emit-telemetry`) projects one memory_requirement_assessed event per
305
+ * row (proposal §6.4: the precision/recall denominator). Unlike the obligation export,
306
+ * EVERY assessment is a telemetry fact regardless of requirement or any deadline claim, so
307
+ * there is no filter here. Deterministically ordered by (session, sequence, assessmentId) so
308
+ * a re-run projects the same events in the same order. */
309
+ function listTurnMemoryAssessments(store, workspaceId) {
310
+ const rows = store.db
311
+ .prepare(`SELECT * FROM turn_memory_assessment
312
+ WHERE workspace_id = ?
313
+ ORDER BY session_id, local_turn_sequence, assessment_id`)
314
+ .all(workspaceId);
315
+ return rows.map(mapAssessmentRow);
316
+ }
317
+ /** Mint this turn's localTurnSequence (MAX+1 per workspace+session) and sampling bucket, then insert
318
+ * the assessment row. This is the side-effecting CORE shared by the two turn-opening entry points:
319
+ * `allocateTurnIdentity` wraps it in its own BEGIN IMMEDIATE, and `openTurnAtomically` runs it inside
320
+ * the same transaction as the obligation insert. It is deliberately NOT exported: every caller must go
321
+ * through one of those transaction boundaries so the sequence allocation stays serialized (the unique
322
+ * (workspace, session, sequence) index is the backstop). */
323
+ function mintAndInsertAssessment(store, d) {
324
+ const { maxSeq } = store.db
325
+ .prepare(`SELECT MAX(local_turn_sequence) AS maxSeq
326
+ FROM turn_memory_assessment
327
+ WHERE workspace_id = ? AND session_id = ?`)
328
+ .get(d.workspaceId, d.sessionId);
329
+ const localTurnSequence = (maxSeq ?? 0) + 1;
330
+ const rec = {
331
+ ...d,
332
+ localTurnSequence,
333
+ // The bucket binds to the same natural key as the row's UNIQUE constraint, so it must be
334
+ // minted here, after the sequence, never on the draft (R3 P0.9; ce0-sampling-bucket.ts).
335
+ samplingBucket: (0, ce0_sampling_bucket_1.samplingBucketFor)({
336
+ workspaceId: d.workspaceId,
337
+ sessionId: d.sessionId,
338
+ localTurnSequence,
339
+ }),
340
+ };
341
+ insertTurnMemoryAssessment(store, rec);
342
+ return rec;
343
+ }
344
+ /** Mint this turn's LocalTurnIdentity and persist its assessment in one serialized
345
+ * transaction: `BEGIN IMMEDIATE; nextSequence = MAX(localTurnSequence) + 1; insert`.
346
+ * BEGIN IMMEDIATE takes the write lock up front so two UserPromptSubmit processes for
347
+ * the same (workspace, session) cannot read the same MAX and collide; the unique
348
+ * (workspace, session, sequence) index is the backstop. Returns the persisted record
349
+ * carrying the minted sequence. */
350
+ function allocateTurnIdentity(store, draft) {
351
+ const mint = store.db.transaction((d) => mintAndInsertAssessment(store, d));
352
+ return mint.immediate(draft);
353
+ }
354
+ /** Open a turn ATOMICALLY (proposal §1.3 req 1 physical resolution, R4 P0.4): in ONE BEGIN IMMEDIATE
355
+ * transaction, mint the LocalTurnIdentity, insert the assessment, and (when the turn is REQUIRED)
356
+ * insert its obligation. `buildObligation` runs INSIDE the transaction so the obligation it returns can
357
+ * carry the localTurnSequence just minted; returning null is a non-REQUIRED turn whose only row is the
358
+ * assessment. If the obligation insert throws, the assessment insert and the sequence allocation roll
359
+ * back with it, so a REQUIRED turn is never left half-open (an assessment with no obligation to grade,
360
+ * which would silently undercount the graded obligation set against the assessed-REQUIRED set). */
361
+ function openTurnAtomically(store, draft, buildObligation) {
362
+ const run = store.db.transaction((d) => {
363
+ const assessment = mintAndInsertAssessment(store, d);
364
+ const obligation = buildObligation(assessment);
365
+ if (obligation)
366
+ insertTurnRuleObligation(store, obligation);
367
+ return { assessment, obligation };
368
+ });
369
+ return run.immediate(draft);
370
+ }
371
+ /** Resolve the LocalTurnIdentity a later hook should inherit: the highest-sequence
372
+ * assessment for the (workspace, session), or null if the turn has none yet. */
373
+ function resolveLatestTurnIdentity(store, coord) {
374
+ const row = store.db
375
+ .prepare(`SELECT workspace_id, session_id, local_turn_sequence
376
+ FROM turn_memory_assessment
377
+ WHERE workspace_id = ? AND session_id = ?
378
+ ORDER BY local_turn_sequence DESC
379
+ LIMIT 1`)
380
+ .get(coord.workspaceId, coord.sessionId);
381
+ if (!row)
382
+ return null;
383
+ return {
384
+ workspaceId: row.workspace_id,
385
+ sessionId: row.session_id,
386
+ localTurnSequence: row.local_turn_sequence,
387
+ };
388
+ }
389
+ // ---------------------------------------------------------------------------
390
+ // turn_rule_obligation
391
+ // ---------------------------------------------------------------------------
392
+ function insertTurnRuleObligation(store, rec) {
393
+ store.db
394
+ .prepare(`INSERT INTO turn_rule_obligation
395
+ (obligation_id, workspace_id, session_id, local_turn_sequence, rule_id,
396
+ rule_version_id, required_subjects, subject_satisfaction, status, state_version,
397
+ deadline_claimed_at, deadline_claimed_version, response_hash, outcome,
398
+ canonical_payload_hash)
399
+ VALUES
400
+ (@obligation_id, @workspace_id, @session_id, @local_turn_sequence, @rule_id,
401
+ @rule_version_id, @required_subjects, @subject_satisfaction, @status, @state_version,
402
+ @deadline_claimed_at, @deadline_claimed_version, @response_hash, @outcome,
403
+ @canonical_payload_hash)`)
404
+ .run({
405
+ obligation_id: rec.obligationId,
406
+ workspace_id: rec.workspaceId,
407
+ session_id: rec.sessionId,
408
+ local_turn_sequence: rec.localTurnSequence,
409
+ rule_id: rec.ruleId,
410
+ rule_version_id: rec.ruleVersionId,
411
+ required_subjects: JSON.stringify(rec.requiredSubjects),
412
+ subject_satisfaction: JSON.stringify(rec.subjectSatisfaction),
413
+ status: rec.status,
414
+ state_version: rec.stateVersion,
415
+ deadline_claimed_at: rec.deadlineClaimedAt,
416
+ deadline_claimed_version: rec.deadlineClaimedVersion,
417
+ response_hash: rec.responseHash,
418
+ outcome: rec.outcome,
419
+ canonical_payload_hash: rec.canonicalPayloadHash,
420
+ });
421
+ }
422
+ function mapObligationRow(row) {
423
+ return {
424
+ obligationId: row.obligation_id,
425
+ workspaceId: row.workspace_id,
426
+ sessionId: row.session_id,
427
+ localTurnSequence: row.local_turn_sequence,
428
+ ruleId: row.rule_id,
429
+ ruleVersionId: row.rule_version_id,
430
+ requiredSubjects: JSON.parse(row.required_subjects),
431
+ subjectSatisfaction: JSON.parse(row.subject_satisfaction),
432
+ status: row.status,
433
+ stateVersion: row.state_version,
434
+ deadlineClaimedAt: row.deadline_claimed_at ?? null,
435
+ deadlineClaimedVersion: row.deadline_claimed_version ?? null,
436
+ responseHash: row.response_hash ?? null,
437
+ outcome: row.outcome ?? null,
438
+ canonicalPayloadHash: row.canonical_payload_hash,
439
+ };
440
+ }
441
+ function getTurnRuleObligation(store, obligationId) {
442
+ const row = store.db
443
+ .prepare(`SELECT * FROM turn_rule_obligation WHERE obligation_id = ?`)
444
+ .get(obligationId);
445
+ if (!row)
446
+ return null;
447
+ return mapObligationRow(row);
448
+ }
449
+ /** List every obligation in the workspace whose first-Stop deadline has been claimed (the
450
+ * frozen, due-resolved set the `mla evidence` export labels). A live obligation whose deadline
451
+ * is still null is NOT exportable: its eligibility boundary is not yet fixed. FINALIZED rows are
452
+ * INCLUDED here (this is a mechanical read of the claimed set); the export workflow, not the
453
+ * store, decides to skip already-labeled ones. Deterministically ordered by (session, sequence,
454
+ * obligationId) so the JSONL artifact is stable across runs. */
455
+ function listDeadlineClaimedObligations(store, workspaceId) {
456
+ const rows = store.db
457
+ .prepare(`SELECT * FROM turn_rule_obligation
458
+ WHERE workspace_id = ? AND deadline_claimed_at IS NOT NULL
459
+ ORDER BY session_id, local_turn_sequence, obligation_id`)
460
+ .all(workspaceId);
461
+ return rows.map(mapObligationRow);
462
+ }
463
+ // ---------------------------------------------------------------------------
464
+ // consultation_attempt
465
+ // ---------------------------------------------------------------------------
466
+ function insertConsultationAttempt(store, rec) {
467
+ store.db
468
+ .prepare(`INSERT INTO consultation_attempt
469
+ (consultation_id, workspace_id, session_id, local_turn_sequence,
470
+ source, consultation_subjects, execution, result, delivered_to_answering_context,
471
+ ordering_token, created_at)
472
+ VALUES
473
+ (@consultation_id, @workspace_id, @session_id, @local_turn_sequence,
474
+ @source, @consultation_subjects, @execution, @result, @delivered_to_answering_context,
475
+ @ordering_token, @created_at)`)
476
+ .run({
477
+ consultation_id: rec.consultationId,
478
+ workspace_id: rec.workspaceId,
479
+ session_id: rec.sessionId,
480
+ local_turn_sequence: rec.localTurnSequence,
481
+ source: rec.source,
482
+ consultation_subjects: JSON.stringify(rec.consultationSubjects),
483
+ execution: rec.execution,
484
+ result: rec.result,
485
+ delivered_to_answering_context: rec.deliveredToAnsweringContext ? 1 : 0,
486
+ ordering_token: rec.orderingToken,
487
+ created_at: rec.createdAt,
488
+ });
489
+ }
490
+ /** Record one governed-memory consultation, minting its orderingToken in the same serialized
491
+ * transaction: `BEGIN IMMEDIATE; nextToken = MAX(ordering_token) + 1; insert`. The token is a
492
+ * per-turn monotonic position (scoped to the (workspace, session, sequence) the consultation
493
+ * belongs to), NOT a wall clock: the first-Stop deadline claim reads the high-water token as
494
+ * the eligibility boundary, and the reducer breaks proof ties on it. BEGIN IMMEDIATE takes the
495
+ * write lock up front so two concurrent appends in one turn cannot read the same MAX. Returns
496
+ * the persisted record carrying the minted token. */
497
+ function appendConsultationAttempt(store, draft) {
498
+ const mint = store.db.transaction((d) => {
499
+ const { maxTok } = store.db
500
+ .prepare(`SELECT MAX(ordering_token) AS maxTok
501
+ FROM consultation_attempt
502
+ WHERE workspace_id = ? AND session_id = ? AND local_turn_sequence = ?`)
503
+ .get(d.workspaceId, d.sessionId, d.localTurnSequence);
504
+ const rec = { ...d, orderingToken: (maxTok ?? 0) + 1 };
505
+ insertConsultationAttempt(store, rec);
506
+ return rec;
507
+ });
508
+ return mint.immediate(draft);
509
+ }
510
+ function mapConsultationRow(row) {
511
+ return {
512
+ consultationId: row.consultation_id,
513
+ workspaceId: row.workspace_id,
514
+ sessionId: row.session_id,
515
+ localTurnSequence: row.local_turn_sequence,
516
+ source: row.source,
517
+ consultationSubjects: JSON.parse(row.consultation_subjects),
518
+ execution: row.execution,
519
+ result: row.result ?? null,
520
+ deliveredToAnsweringContext: row.delivered_to_answering_context === 1,
521
+ orderingToken: row.ordering_token,
522
+ createdAt: row.created_at,
523
+ };
524
+ }
525
+ function getConsultationAttempt(store, consultationId) {
526
+ const row = store.db
527
+ .prepare(`SELECT * FROM consultation_attempt WHERE consultation_id = ?`)
528
+ .get(consultationId);
529
+ if (!row)
530
+ return null;
531
+ return mapConsultationRow(row);
532
+ }
533
+ /** List one turn's consultations, ordered by (orderingToken, consultationId): the same total
534
+ * order the satisfaction reducer imposes, so the export's raw facts read in eligibility order.
535
+ * Scoped to the exact (workspace, session, localTurnSequence) coordinate. */
536
+ function listConsultationsForTurn(store, coord) {
537
+ const rows = store.db
538
+ .prepare(`SELECT * FROM consultation_attempt
539
+ WHERE workspace_id = ? AND session_id = ? AND local_turn_sequence = ?
540
+ ORDER BY ordering_token, consultation_id`)
541
+ .all(coord.workspaceId, coord.sessionId, coord.localTurnSequence);
542
+ return rows.map(mapConsultationRow);
543
+ }
544
+ /**
545
+ * §2.3 Stage A: the first Stop's immediate, I/O-free observation of a turn. In one serialized
546
+ * `BEGIN IMMEDIATE` transaction it does two things:
547
+ * 1. Stamps `stopObservedAt` on the turn's assessment for EVERY classified turn (REQUIRED or
548
+ * not), if-null so a later Stop never overwrites the first observation. A NOT_REQUIRED /
549
+ * UNKNOWN turn has an assessment but no obligation; it is still stamped, so the offline
550
+ * false-negative recall sample carries the same answer evidence as a flagged turn.
551
+ * 2. When an obligation is present, freezes the eligibility boundary: it finds the obligation by
552
+ * its unique turn key, reads the turn's high-water orderingToken (the boundary), and
553
+ * CAS-advances stateVersion while writing the deadline fields. The status column is
554
+ * deliberately untouched: the claim fixes the boundary, it does not declare the obligation
555
+ * satisfied (satisfaction is recomputed offline over the frozen eligible set).
556
+ * The deadline claim is idempotent: once a deadline is set, a later Stop returns ALREADY_CLAIMED
557
+ * and never moves the boundary, so a consultation that arrives after the first Stop (a higher
558
+ * token) cannot retroactively become eligible. No filesystem read happens here; the response
559
+ * snapshot is the best-effort Stage B, outside this transaction. BEGIN IMMEDIATE serializes
560
+ * concurrent Stops so exactly one wins the claim and the first stamp.
561
+ */
562
+ function claimFirstStop(store, coord, ruleVersionId, now) {
563
+ const run = store.db.transaction(() => {
564
+ // Stage A step 1: stamp the assessment if-null. A pure write, no I/O; a harmless no-op when no
565
+ // assessment row exists for the coord (e.g. a Stop for a turn CE0 never assessed).
566
+ store.db
567
+ .prepare(`UPDATE turn_memory_assessment
568
+ SET stop_observed_at = ?
569
+ WHERE workspace_id = ? AND session_id = ? AND local_turn_sequence = ?
570
+ AND stop_observed_at IS NULL`)
571
+ .run(now(), coord.workspaceId, coord.sessionId, coord.localTurnSequence);
572
+ // Stage A step 2: claim the obligation deadline, when an obligation is present.
573
+ const key = store.db
574
+ .prepare(`SELECT obligation_id FROM turn_rule_obligation
575
+ WHERE workspace_id = ? AND session_id = ? AND local_turn_sequence = ?
576
+ AND rule_version_id = ?`)
577
+ .get(coord.workspaceId, coord.sessionId, coord.localTurnSequence, ruleVersionId);
578
+ if (!key)
579
+ return { status: "NO_OBLIGATION" };
580
+ const obligation = getTurnRuleObligation(store, key.obligation_id);
581
+ if (obligation.deadlineClaimedAt !== null) {
582
+ return {
583
+ status: "ALREADY_CLAIMED",
584
+ claim: {
585
+ obligationId: obligation.obligationId,
586
+ deadlineClaimedAt: obligation.deadlineClaimedAt,
587
+ deadlineClaimedVersion: obligation.deadlineClaimedVersion,
588
+ stateVersion: obligation.stateVersion,
589
+ },
590
+ };
591
+ }
592
+ const { maxTok } = store.db
593
+ .prepare(`SELECT MAX(ordering_token) AS maxTok FROM consultation_attempt
594
+ WHERE workspace_id = ? AND session_id = ? AND local_turn_sequence = ?`)
595
+ .get(coord.workspaceId, coord.sessionId, coord.localTurnSequence);
596
+ const boundary = maxTok ?? 0;
597
+ const observed = obligation.stateVersion;
598
+ store.db
599
+ .prepare(`UPDATE turn_rule_obligation
600
+ SET deadline_claimed_at = ?, deadline_claimed_version = ?, state_version = ?
601
+ WHERE obligation_id = ? AND state_version = ? AND deadline_claimed_at IS NULL`)
602
+ .run(boundary, observed, observed + 1, obligation.obligationId, observed);
603
+ return {
604
+ status: "CLAIMED",
605
+ claim: {
606
+ obligationId: obligation.obligationId,
607
+ deadlineClaimedAt: boundary,
608
+ deadlineClaimedVersion: observed,
609
+ stateVersion: observed + 1,
610
+ },
611
+ };
612
+ });
613
+ return run.immediate();
614
+ }
615
+ /**
616
+ * §2.3 Stage B: record the best-effort response snapshot (`responseHash` + `responseSourceRef`) onto
617
+ * an already-stamped assessment, OUTSIDE and AFTER the Stage A deadline transaction. The two fields
618
+ * move together as one pair, never with `stopObservedAt` (proposal lines 1096-1098): Stage A stamped
619
+ * the observation, this fills the response evidence the offline exporter rehydrates from the pointer.
620
+ *
621
+ * Idempotent under repeated Stop continuations (P0.6): a later Stop may fill a snapshot that is still
622
+ * missing, but it may NEVER overwrite one that already completed. The guard is `response_hash IS NULL`
623
+ * checked inside one serialized `BEGIN IMMEDIATE` transaction, so concurrent Stops cannot both write.
624
+ * `stop_observed_at` is deliberately untouched here; only the response pair is written.
625
+ */
626
+ function recordStopResponseSnapshot(store, coord, snapshot) {
627
+ const run = store.db.transaction(() => {
628
+ const existing = store.db
629
+ .prepare(`SELECT response_hash FROM turn_memory_assessment
630
+ WHERE workspace_id = ? AND session_id = ? AND local_turn_sequence = ?`)
631
+ .get(coord.workspaceId, coord.sessionId, coord.localTurnSequence);
632
+ if (!existing)
633
+ return { status: "NO_ASSESSMENT" };
634
+ if (existing.response_hash != null)
635
+ return { status: "ALREADY_RECORDED" };
636
+ store.db
637
+ .prepare(`UPDATE turn_memory_assessment
638
+ SET response_hash = ?, response_source_ref = ?
639
+ WHERE workspace_id = ? AND session_id = ? AND local_turn_sequence = ?
640
+ AND response_hash IS NULL`)
641
+ .run(snapshot.responseHash, JSON.stringify(snapshot.responseSourceRef), coord.workspaceId, coord.sessionId, coord.localTurnSequence);
642
+ return { status: "RECORDED" };
643
+ });
644
+ return run.immediate();
645
+ }
646
+ /**
647
+ * Write a turn obligation's terminal outcome and move it to FINALIZED, guarded by a
648
+ * compare-and-swap on stateVersion (the same token the deadline claim advances). The human
649
+ * labeler chooses the outcome offline (§2.3); this is the only thing that finalizes a due turn.
650
+ * In one serialized `BEGIN IMMEDIATE` transaction: read the stored stateVersion, fail closed as
651
+ * CAS_CONFLICT if it does not match the caller's expectation (a concurrent finalize or a stale
652
+ * label), else set status='FINALIZED', write the outcome, and advance stateVersion. The CAS makes
653
+ * a re-import of an already-finalized obligation a clean conflict, never a double-write.
654
+ */
655
+ function finalizeObligation(store, cmd) {
656
+ const run = store.db.transaction(() => {
657
+ const obligation = getTurnRuleObligation(store, cmd.obligationId);
658
+ if (!obligation) {
659
+ return { status: "NO_OBLIGATION", obligationId: cmd.obligationId };
660
+ }
661
+ if (obligation.stateVersion !== cmd.expectedStateVersion) {
662
+ return {
663
+ status: "CAS_CONFLICT",
664
+ obligationId: cmd.obligationId,
665
+ expectedStateVersion: cmd.expectedStateVersion,
666
+ actualStateVersion: obligation.stateVersion,
667
+ };
668
+ }
669
+ const next = cmd.expectedStateVersion + 1;
670
+ store.db
671
+ .prepare(`UPDATE turn_rule_obligation
672
+ SET status = 'FINALIZED', outcome = ?, state_version = ?
673
+ WHERE obligation_id = ? AND state_version = ?`)
674
+ .run(cmd.outcome, next, cmd.obligationId, cmd.expectedStateVersion);
675
+ return {
676
+ status: "FINALIZED",
677
+ obligationId: cmd.obligationId,
678
+ outcome: cmd.outcome,
679
+ stateVersion: next,
680
+ };
681
+ });
682
+ return run.immediate();
683
+ }