run-dmcp 0.5.0 → 0.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.
@@ -6,7 +6,7 @@ export declare const SERVER_NAME = "dmcp";
6
6
  * the published package version by src/__tests__/serverVersion.test.ts --
7
7
  * this said "0.3.0" for the whole of 0.4.0, because a release bumps
8
8
  * package.json and nothing was watching this. */
9
- export declare const SERVER_VERSION = "0.5.0";
9
+ export declare const SERVER_VERSION = "0.6.0";
10
10
  /**
11
11
  * Build an MCP server with every CORE tool, resource and prompt this engine
12
12
  * serves -- entities, facts, events, the timeline, and the entity/property
@@ -45,7 +45,7 @@ export const SERVER_NAME = "dmcp";
45
45
  * the published package version by src/__tests__/serverVersion.test.ts --
46
46
  * this said "0.3.0" for the whole of 0.4.0, because a release bumps
47
47
  * package.json and nothing was watching this. */
48
- export const SERVER_VERSION = "0.5.0";
48
+ export const SERVER_VERSION = "0.6.0";
49
49
  /**
50
50
  * Build an MCP server with every CORE tool, resource and prompt this engine
51
51
  * serves -- entities, facts, events, the timeline, and the entity/property
@@ -80,6 +80,13 @@ export interface TimelineExportFact {
80
80
  validFromT: T;
81
81
  validToT: T | null;
82
82
  irreversible: boolean;
83
+ /** The one hop of causality (design §5.2c, issue #30) -- the event that
84
+ * opened this fact, or null when none is recorded. Optional, not just
85
+ * nullable: a v1 artifact written before issue #30 landed carries no such
86
+ * field at all, and `importTimeline` treats an absent field and an
87
+ * explicit `null` identically -- there was never a recorded hop for
88
+ * either, so there is nothing to guess and no reason to refuse. */
89
+ openedByEventId?: string | null;
83
90
  }
84
91
  export interface TimelineExportEvent {
85
92
  id: string;
@@ -158,10 +165,12 @@ export declare function exportTimeline(gameId: string): TimelineExport;
158
165
  * artifact shape above) is stamped fresh at import time; it was never
159
166
  * exported and never round-trips.
160
167
  *
161
- * `entities` are inserted before `facts` because `facts.entity_id` is a
162
- * real foreign key and this database runs with `PRAGMA foreign_keys = ON`
163
- * (`../db/connection.ts`) -- inserting out of order would fail loudly
164
- * rather than silently, but there is no reason to invite the failure.
168
+ * `entities` are inserted before `events` and `facts` because `facts.entity_id`
169
+ * is a real foreign key; `events` are inserted before `facts` (issue #30) for
170
+ * the identical reason now that `facts.opened_by_event_id` is one too --
171
+ * this database runs with `PRAGMA foreign_keys = ON` (`../db/connection.ts`),
172
+ * so inserting out of order would fail loudly rather than silently, but
173
+ * there is no reason to invite the failure.
165
174
  */
166
175
  export declare function importTimeline(artifact: TimelineExport): TimelineImportResult;
167
176
  /**
@@ -103,7 +103,7 @@ function readTimeline(gameId) {
103
103
  // scoped by joining back to entities, exactly the way replay.ts scopes
104
104
  // "what was true of them" to "who was alive."
105
105
  const factRows = db
106
- .prepare(`SELECT f.id, f.entity_id, f.key, f.value, f.valid_from_t, f.valid_to_t, f.irreversible
106
+ .prepare(`SELECT f.id, f.entity_id, f.key, f.value, f.valid_from_t, f.valid_to_t, f.irreversible, f.opened_by_event_id
107
107
  FROM facts f
108
108
  JOIN entities e ON e.id = f.entity_id
109
109
  WHERE e.game_id = ?
@@ -137,6 +137,7 @@ function readTimeline(gameId) {
137
137
  validFromT: row.valid_from_t,
138
138
  validToT: row.valid_to_t,
139
139
  irreversible: Boolean(row.irreversible),
140
+ openedByEventId: row.opened_by_event_id,
140
141
  })),
141
142
  events: eventRows.map((row) => ({
142
143
  id: row.id,
@@ -266,10 +267,12 @@ function assertTargetIsEmpty(gameId) {
266
267
  * artifact shape above) is stamped fresh at import time; it was never
267
268
  * exported and never round-trips.
268
269
  *
269
- * `entities` are inserted before `facts` because `facts.entity_id` is a
270
- * real foreign key and this database runs with `PRAGMA foreign_keys = ON`
271
- * (`../db/connection.ts`) -- inserting out of order would fail loudly
272
- * rather than silently, but there is no reason to invite the failure.
270
+ * `entities` are inserted before `events` and `facts` because `facts.entity_id`
271
+ * is a real foreign key; `events` are inserted before `facts` (issue #30) for
272
+ * the identical reason now that `facts.opened_by_event_id` is one too --
273
+ * this database runs with `PRAGMA foreign_keys = ON` (`../db/connection.ts`),
274
+ * so inserting out of order would fail loudly rather than silently, but
275
+ * there is no reason to invite the failure.
273
276
  */
274
277
  export function importTimeline(artifact) {
275
278
  assertValidArtifactShape(artifact);
@@ -289,14 +292,17 @@ export function importTimeline(artifact) {
289
292
  for (const entity of artifact.entities) {
290
293
  insertEntity.run(entity.id, entity.gameId, entity.kind, entity.name, entity.createdAtT, entity.destroyedAtT);
291
294
  }
292
- const insertFact = db.prepare(`INSERT INTO facts (id, entity_id, key, value, valid_from_t, valid_to_t, irreversible) VALUES (?, ?, ?, ?, ?, ?, ?)`);
293
- for (const fact of artifact.facts) {
294
- insertFact.run(fact.id, fact.entityId, fact.key, fact.value, fact.validFromT, fact.validToT, fact.irreversible ? 1 : 0);
295
- }
295
+ // Events before facts (issue #30): `facts.opened_by_event_id` is a real
296
+ // foreign key into `events` now, on top of `facts.entity_id`'s existing
297
+ // one into `entities` -- see this function's doc comment.
296
298
  const insertEvent = db.prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, ?, ?, ?)`);
297
299
  for (const event of artifact.events) {
298
300
  insertEvent.run(event.id, event.gameId, event.atT, event.kind, event.description, event.causes);
299
301
  }
302
+ const insertFact = db.prepare(`INSERT INTO facts (id, entity_id, key, value, valid_from_t, valid_to_t, irreversible, opened_by_event_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
303
+ for (const fact of artifact.facts) {
304
+ insertFact.run(fact.id, fact.entityId, fact.key, fact.value, fact.validFromT, fact.validToT, fact.irreversible ? 1 : 0, fact.openedByEventId ?? null);
305
+ }
300
306
  return {
301
307
  gameId: artifact.gameId,
302
308
  entities: artifact.entities.length,
@@ -1,7 +1,11 @@
1
1
  import { getDatabase } from "../db/connection.js";
2
2
  import { assertT } from "./t.js";
3
- import { openingEventId } from "./provenance.js";
4
- function toIrreversibleFact(row, gameId) {
3
+ /** `gameId` is no longer used to look up the hop (issue #30: it is a stored
4
+ * column on the fact row itself, never derived), but stays a parameter so
5
+ * every call site here keeps naming the game it is working in -- and so a
6
+ * future caller that genuinely needs to re-scope by game has somewhere to
7
+ * put it without changing every signature in this file again. */
8
+ function toIrreversibleFact(row, _gameId) {
5
9
  assertT(row.valid_from_t);
6
10
  return {
7
11
  factId: row.id,
@@ -9,7 +13,7 @@ function toIrreversibleFact(row, gameId) {
9
13
  key: row.key,
10
14
  value: row.value,
11
15
  validFromT: row.valid_from_t,
12
- openedByEventId: openingEventId(gameId, row.entity_id, row.valid_from_t),
16
+ openedByEventId: row.opened_by_event_id,
13
17
  };
14
18
  }
15
19
  /**
@@ -44,7 +48,7 @@ export function declareIrreversible(params) {
44
48
  // order. Same tiebreak as irreversibleFactFor below, so the two functions
45
49
  // can never disagree about which row they mean.
46
50
  const open = db
47
- .prepare(`SELECT id, entity_id, key, value, valid_from_t FROM facts
51
+ .prepare(`SELECT id, entity_id, key, value, valid_from_t, opened_by_event_id FROM facts
48
52
  WHERE entity_id = ? AND key = ? AND valid_to_t IS NULL
49
53
  ORDER BY valid_from_t DESC, id DESC
50
54
  LIMIT 1`)
@@ -73,7 +77,7 @@ export function irreversibleFactFor(entityId, key) {
73
77
  if (!entity)
74
78
  return null;
75
79
  const row = db
76
- .prepare(`SELECT id, entity_id, key, value, valid_from_t FROM facts
80
+ .prepare(`SELECT id, entity_id, key, value, valid_from_t, opened_by_event_id FROM facts
77
81
  WHERE entity_id = ? AND key = ? AND irreversible = 1
78
82
  ORDER BY valid_from_t DESC, id DESC
79
83
  LIMIT 1`)
@@ -92,7 +96,8 @@ export function irreversibleFactFor(entityId, key) {
92
96
  export function listIrreversibleFacts(params) {
93
97
  const db = getDatabase();
94
98
  let query = `
95
- SELECT f.id AS id, f.entity_id AS entity_id, f.key AS key, f.value AS value, f.valid_from_t AS valid_from_t
99
+ SELECT f.id AS id, f.entity_id AS entity_id, f.key AS key, f.value AS value, f.valid_from_t AS valid_from_t,
100
+ f.opened_by_event_id AS opened_by_event_id
96
101
  FROM facts f
97
102
  JOIN entities e ON e.id = f.entity_id
98
103
  WHERE e.game_id = ? AND f.irreversible = 1
@@ -1,6 +1,5 @@
1
1
  import { getDatabase } from "../db/connection.js";
2
2
  import { assertT, compareT } from "./t.js";
3
- import { openingEventId } from "./provenance.js";
4
3
  /**
5
4
  * The outbound half of authority (design §5.2b/§5.2c, GitHub issues #11 and
6
5
  * #12): "Here is what is true; depict it, do not argue with it." One
@@ -47,7 +46,10 @@ import { openingEventId } from "./provenance.js";
47
46
  * opened it" in this codebase, not a second copy grown for this module.
48
47
  */
49
48
  export const NARRATION_CONSTRAINT_FORMAT_VERSION = 1;
50
- function toConstraintFact(row, gameId) {
49
+ /** `gameId` is unused now that the hop is a stored column (issue #30) rather
50
+ * than derived per-row, but the parameter stays -- see irreversible.ts's
51
+ * identical note on `toIrreversibleFact`. */
52
+ function toConstraintFact(row, _gameId) {
51
53
  assertT(row.valid_from_t);
52
54
  if (row.valid_to_t !== null)
53
55
  assertT(row.valid_to_t);
@@ -61,7 +63,7 @@ function toConstraintFact(row, gameId) {
61
63
  irreversible: Boolean(row.irreversible),
62
64
  entityKind: row.entity_kind,
63
65
  entityName: row.entity_name,
64
- openedByEventId: openingEventId(gameId, row.entity_id, row.valid_from_t),
66
+ openedByEventId: row.opened_by_event_id,
65
67
  };
66
68
  }
67
69
  /**
@@ -125,7 +127,7 @@ export function narrationConstraintAt(params) {
125
127
  const rows = db
126
128
  .prepare(`SELECT f.id AS id, f.entity_id AS entity_id, f.key AS key, f.value AS value,
127
129
  f.valid_from_t AS valid_from_t, f.valid_to_t AS valid_to_t, f.irreversible AS irreversible,
128
- e.kind AS entity_kind, e.name AS entity_name
130
+ e.kind AS entity_kind, e.name AS entity_name, f.opened_by_event_id AS opened_by_event_id
129
131
  FROM facts f
130
132
  JOIN entities e ON e.id = f.entity_id
131
133
  WHERE e.game_id = ?
@@ -41,10 +41,16 @@ function tExpr(gidExpr) {
41
41
  /**
42
42
  * `AFTER INSERT`: ensure the game's clock row exists, advance it, insert the
43
43
  * entity, insert one fact per non-NULL column, insert a `<kind>.created`
44
- * event. Column names are interpolated directly (never bound as parameters)
45
- * because they come from this codebase's own `pragma_table_info`, never
46
- * from anything a caller supplied -- there is no user input anywhere in
47
- * this SQL (trigger-sql-skeleton trap #6).
44
+ * event, then (issue #30) stamp every fact this firing just opened with that
45
+ * event's id via `last_insert_rowid()` -- the one hop of causality (design
46
+ * §5.2c), recorded rather than derived later. The `opened_by_event_id IS
47
+ * NULL` guard is what keeps a repeated `t` on a non-`sequence` axis correct:
48
+ * it restricts the stamp to facts THIS firing opened, never one a previous
49
+ * firing at the same `t` already stamped. Column names are interpolated
50
+ * directly (never bound as parameters) because they come from this
51
+ * codebase's own `pragma_table_info`, never from anything a caller supplied
52
+ * -- there is no user input anywhere in this SQL (trigger-sql-skeleton
53
+ * trap #6).
48
54
  */
49
55
  function buildInsertTrigger(row, cols) {
50
56
  const gid = `NEW.${row.gameIdColumn}`;
@@ -72,6 +78,9 @@ ${factInserts}
72
78
  INSERT INTO events (id, game_id, at_t, kind, description, causes)
73
79
  VALUES (lower(hex(randomblob(16))), ${gid}, ${t}, '${row.kind}.created', '${row.kind} created',
74
80
  json_object('table', '${row.table}', 'row_id', NEW.id));
81
+
82
+ UPDATE facts SET opened_by_event_id = (SELECT id FROM events WHERE rowid = last_insert_rowid())
83
+ WHERE entity_id = NEW.id AND valid_from_t = ${t} AND opened_by_event_id IS NULL;
75
84
  END;
76
85
  `;
77
86
  }
@@ -84,6 +93,11 @@ ${factInserts}
84
93
  * column: reversed, the open's subquery would still see the value the
85
94
  * close was about to retire and write nothing (trap #3). The five-case
86
95
  * table this produces is walked by the test suite, not re-derived here.
96
+ * After the `<kind>.updated` event lands, every fact this firing just opened
97
+ * (across every column touched) is stamped with that event's id -- see
98
+ * `buildInsertTrigger`'s doc comment for why the `opened_by_event_id IS
99
+ * NULL` guard is what keeps this correct when a non-`sequence` axis repeats
100
+ * a `t` across firings (issue #30).
87
101
  */
88
102
  function buildUpdateTrigger(row, cols) {
89
103
  const gid = `NEW.${row.gameIdColumn}`;
@@ -111,6 +125,9 @@ ${perColumn}
111
125
  INSERT INTO events (id, game_id, at_t, kind, description, causes)
112
126
  VALUES (lower(hex(randomblob(16))), ${gid}, ${t}, '${row.kind}.updated', '${row.kind} updated',
113
127
  json_object('table', '${row.table}', 'row_id', NEW.id));
128
+
129
+ UPDATE facts SET opened_by_event_id = (SELECT id FROM events WHERE rowid = last_insert_rowid())
130
+ WHERE entity_id = NEW.id AND valid_from_t = ${t} AND opened_by_event_id IS NULL;
114
131
  END;
115
132
  `;
116
133
  }
@@ -32,35 +32,28 @@ export interface FactProvenance {
32
32
  openedByEventId: string | null;
33
33
  }
34
34
  /**
35
- * The one hop of causality (design §5.2c): the event of `gameId` whose
36
- * `at_t` equals the fact's `valid_from_t` and whose `causes` JSON names this
37
- * entity as the row it was written for. `causes` is produced entirely by
38
- * this codebase's own projection triggers (`json_object('table', ...,
39
- * 'row_id', NEW.id)` in projection.ts) -- matching `$.row_id` here is a
40
- * literal comparison against a token we defined in output we generated, not
41
- * an attempt to understand what any event "means" (hard rule 4). Ordered
42
- * deterministically (`at_t`, then `id`) and only the first row is taken --
43
- * one hop, never a chain, never a trace of how the engine got here.
35
+ * The one hop of causality (design §5.2c), READ rather than derived (issue
36
+ * #30). `facts.opened_by_event_id` is stamped by the projection triggers'
37
+ * `_ai`/`_au` bodies (projection.ts) at the moment a fact opens, in the same
38
+ * firing, via `last_insert_rowid()` against the event they just inserted --
39
+ * so this is a direct column lookup now, not a search over `events` keyed by
40
+ * `(at_t, causes.row_id)` with a random-hex tiebreak among rows sharing a
41
+ * `t`. That derivation is gone, along with the failure modes it carried: it
42
+ * could return null for an event whose `causes` was not valid JSON, and it
43
+ * broke ties among same-`t` events arbitrarily.
44
44
  *
45
- * The `CASE WHEN json_valid(causes)` wrapper is load-bearing, not defensive
46
- * decoration. `events.causes` has no CHECK constraint, and SQLite's
47
- * `json_extract` RAISES "malformed JSON" rather than returning NULL when it
48
- * meets a value that is not JSON -- and that error belongs to the whole
49
- * query, not to the offending row, so a single bad row anywhere in this
50
- * game's events would make every function that calls this throw, including
51
- * ones that have nothing to do with that event. That is reachable in
52
- * practice: timeline import (export.ts) carries `causes` through verbatim
53
- * by design, because an importer that rewrote a recorded cause would be
54
- * inventing history. A hop of provenance must never be able to fail the
55
- * write it annotates, so a row we cannot read simply does not match.
56
- * Written as CASE rather than `json_valid(causes) AND json_extract(...)`
57
- * because SQLite does not guarantee the evaluation order of AND operands --
58
- * the planner may reorder them, and then the guard is decoration that
59
- * happens to work today.
45
+ * Both internal callers of this shape (`irreversible.ts`, `narration.ts`)
46
+ * no longer call this function at all -- each already queries its own fact
47
+ * row and now selects `opened_by_event_id` directly as part of that same
48
+ * query, which is strictly cheaper than a second round trip through here.
60
49
  *
61
- * Moved here verbatim (SQL, doc comment and all) from `irreversible.ts`'s
62
- * former module-private `findOpenedByEventId` -- this is the ONE owner of
63
- * §5.2c's hop now; `irreversible.ts` and `narration.ts` both call this
64
- * rather than each keeping a copy of the query.
50
+ * This function is kept, and re-pointed at the stored column rather than
51
+ * removed, because it is part of this package's published library surface
52
+ * (`src/index.ts` re-exports it) -- issue #30 did not ask for a public API
53
+ * removal, and removing an exported function silently would be exactly the
54
+ * kind of undocumented break root CLAUDE.md's "what we declare is what we
55
+ * mean" section warns against. A caller that already holds
56
+ * `(gameId, entityId, validFromT)` rather than a fact id can still use it;
57
+ * it now does less work to answer the same question.
65
58
  */
66
59
  export declare function openingEventId(gameId: string, entityId: string, validFromT: number): string | null;
@@ -1,45 +1,41 @@
1
1
  import { getDatabase } from "../db/connection.js";
2
2
  /**
3
- * The one hop of causality (design §5.2c): the event of `gameId` whose
4
- * `at_t` equals the fact's `valid_from_t` and whose `causes` JSON names this
5
- * entity as the row it was written for. `causes` is produced entirely by
6
- * this codebase's own projection triggers (`json_object('table', ...,
7
- * 'row_id', NEW.id)` in projection.ts) -- matching `$.row_id` here is a
8
- * literal comparison against a token we defined in output we generated, not
9
- * an attempt to understand what any event "means" (hard rule 4). Ordered
10
- * deterministically (`at_t`, then `id`) and only the first row is taken --
11
- * one hop, never a chain, never a trace of how the engine got here.
3
+ * The one hop of causality (design §5.2c), READ rather than derived (issue
4
+ * #30). `facts.opened_by_event_id` is stamped by the projection triggers'
5
+ * `_ai`/`_au` bodies (projection.ts) at the moment a fact opens, in the same
6
+ * firing, via `last_insert_rowid()` against the event they just inserted --
7
+ * so this is a direct column lookup now, not a search over `events` keyed by
8
+ * `(at_t, causes.row_id)` with a random-hex tiebreak among rows sharing a
9
+ * `t`. That derivation is gone, along with the failure modes it carried: it
10
+ * could return null for an event whose `causes` was not valid JSON, and it
11
+ * broke ties among same-`t` events arbitrarily.
12
12
  *
13
- * The `CASE WHEN json_valid(causes)` wrapper is load-bearing, not defensive
14
- * decoration. `events.causes` has no CHECK constraint, and SQLite's
15
- * `json_extract` RAISES "malformed JSON" rather than returning NULL when it
16
- * meets a value that is not JSON -- and that error belongs to the whole
17
- * query, not to the offending row, so a single bad row anywhere in this
18
- * game's events would make every function that calls this throw, including
19
- * ones that have nothing to do with that event. That is reachable in
20
- * practice: timeline import (export.ts) carries `causes` through verbatim
21
- * by design, because an importer that rewrote a recorded cause would be
22
- * inventing history. A hop of provenance must never be able to fail the
23
- * write it annotates, so a row we cannot read simply does not match.
24
- * Written as CASE rather than `json_valid(causes) AND json_extract(...)`
25
- * because SQLite does not guarantee the evaluation order of AND operands --
26
- * the planner may reorder them, and then the guard is decoration that
27
- * happens to work today.
13
+ * Both internal callers of this shape (`irreversible.ts`, `narration.ts`)
14
+ * no longer call this function at all -- each already queries its own fact
15
+ * row and now selects `opened_by_event_id` directly as part of that same
16
+ * query, which is strictly cheaper than a second round trip through here.
28
17
  *
29
- * Moved here verbatim (SQL, doc comment and all) from `irreversible.ts`'s
30
- * former module-private `findOpenedByEventId` -- this is the ONE owner of
31
- * §5.2c's hop now; `irreversible.ts` and `narration.ts` both call this
32
- * rather than each keeping a copy of the query.
18
+ * This function is kept, and re-pointed at the stored column rather than
19
+ * removed, because it is part of this package's published library surface
20
+ * (`src/index.ts` re-exports it) -- issue #30 did not ask for a public API
21
+ * removal, and removing an exported function silently would be exactly the
22
+ * kind of undocumented break root CLAUDE.md's "what we declare is what we
23
+ * mean" section warns against. A caller that already holds
24
+ * `(gameId, entityId, validFromT)` rather than a fact id can still use it;
25
+ * it now does less work to answer the same question.
33
26
  */
34
27
  export function openingEventId(gameId, entityId, validFromT) {
35
28
  const db = getDatabase();
36
29
  const row = db
37
- .prepare(`SELECT id FROM events
38
- WHERE game_id = ?
39
- AND at_t = ?
40
- AND json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.row_id') = ?
41
- ORDER BY at_t, id
30
+ .prepare(`SELECT f.opened_by_event_id AS id
31
+ FROM facts f
32
+ JOIN entities e ON e.id = f.entity_id
33
+ WHERE e.game_id = ?
34
+ AND f.entity_id = ?
35
+ AND f.valid_from_t = ?
36
+ AND f.opened_by_event_id IS NOT NULL
37
+ ORDER BY f.id
42
38
  LIMIT 1`)
43
- .get(gameId, validFromT, entityId);
39
+ .get(gameId, entityId, validFromT);
44
40
  return row?.id ?? null;
45
41
  }
@@ -84,6 +84,23 @@ export function initializeTimelineSchema() {
84
84
  causes TEXT
85
85
  )
86
86
  `);
87
+ // facts.opened_by_event_id -- issue #30: the one hop of causality (design
88
+ // §5.2c) recorded at the moment it is true, instead of derived at read
89
+ // time by searching `events` for a row whose `at_t`/`causes.row_id` happen
90
+ // to match (the query this replaces lived in provenance.ts, and could
91
+ // return null for a bad `causes` JSON blob, or pick the wrong event of
92
+ // several sharing one `t` by an arbitrary hex-id tiebreak). Idempotent
93
+ // ALTER, ordered after `events` exists -- legal under SQLite's ADD COLUMN
94
+ // restrictions because the implicit default is NULL, and a database that
95
+ // already has this column (every run after the first) just throws here,
96
+ // caught and ignored, same idiom as every other migration in this
97
+ // codebase (root CLAUDE.md).
98
+ try {
99
+ db.exec("ALTER TABLE facts ADD COLUMN opened_by_event_id TEXT REFERENCES events(id)");
100
+ }
101
+ catch {
102
+ // Column already exists.
103
+ }
87
104
  // timeline_clock -- one row per game, tracking the declared axis and its
88
105
  // current position. A game that never declares an axis still needs one
89
106
  // of these once its first entity/fact/event is written, with axis_kind
@@ -152,6 +169,16 @@ export function initializeTimelineSchema() {
152
169
  // change. declareIrreversible() (irreversible.ts) relies on that last
153
170
  // case to make re-declaring idempotent with a single UPDATE and no
154
171
  // separate "already set" check.
172
+ // Third one-way latch (issue #30): NULL -> value, once, exactly like
173
+ // `valid_to_t` above -- once the projection trigger has stamped
174
+ // `opened_by_event_id`, nothing may rewrite it, but the stamp itself (the
175
+ // trigger's own UPDATE, immediately after the fact's INSERT -- see
176
+ // projection.ts) must be allowed through. This clause is load-bearing, not
177
+ // hygiene: probed against better-sqlite3 at this project's own pragma
178
+ // settings, the append-only guard fires on trigger-initiated UPDATEs too.
179
+ // Without it, the projection trigger's own stamp would abort the write it
180
+ // annotates; with it, a stamped edge can never be rewritten -- a re-stamp
181
+ // raises ABORT.
155
182
  db.exec(`
156
183
  DROP TRIGGER IF EXISTS timeline_facts_immutable;
157
184
  CREATE TRIGGER timeline_facts_immutable
@@ -164,8 +191,9 @@ export function initializeTimelineSchema() {
164
191
  OR (NEW.irreversible IS NOT OLD.irreversible
165
192
  AND (OLD.irreversible IS NOT 0 OR NEW.irreversible IS NOT 1))
166
193
  OR (OLD.valid_to_t IS NOT NULL AND NEW.valid_to_t IS NOT OLD.valid_to_t)
194
+ OR (OLD.opened_by_event_id IS NOT NULL AND NEW.opened_by_event_id IS NOT OLD.opened_by_event_id)
167
195
  BEGIN
168
- SELECT RAISE(ABORT, 'timeline: facts are append-only; valid_from_t cannot be rewritten, valid_to_t may only be closed once, and irreversible may only move 0 -> 1');
196
+ SELECT RAISE(ABORT, 'timeline: facts are append-only; valid_from_t cannot be rewritten, valid_to_t may only be closed once, irreversible may only move 0 -> 1, and opened_by_event_id may only be stamped once');
169
197
  END;
170
198
  `);
171
199
  db.exec(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run-dmcp",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "An MCP server for LLM-run interactive fiction where the server owns what is true - including when it was true. A continuation of DMCP.",
5
5
  "license": "MIT",
6
6
  "author": "Derek Ferguson",