run-dmcp 0.5.0 → 0.7.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.
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export { timelineDivergences } from "./timeline/checkpoint.js";
14
14
  export type { Divergence } from "./timeline/checkpoint.js";
15
15
  export { declareIrreversible, irreversibleFactFor, listIrreversibleFacts, } from "./timeline/irreversible.js";
16
16
  export type { IrreversibleFact } from "./timeline/irreversible.js";
17
+ export type { SetTransition } from "./timeline/constrained.js";
17
18
  export { openingEventId } from "./timeline/provenance.js";
18
19
  export type { FactProvenance } from "./timeline/provenance.js";
19
20
  export { narrationConstraintAt, contradictions, NARRATION_CONSTRAINT_FORMAT_VERSION, } from "./timeline/narration.js";
@@ -22,7 +23,7 @@ export { writeConstrainedValue, transferConstrainedValue, valueHistory, } from "
22
23
  export type { ValueTransition } from "./timeline/constrained.js";
23
24
  export { ConstraintViolationError, constraintsFor, conservedConstraintFor } from "./timeline/registry.js";
24
25
  export { createResolver, ResolveProtocolError } from "./timeline/resolve.js";
25
- export type { Mechanic, Resolver, Proposal, Expectation, AdjudicationInput, Adjudication, IntendedChange, IntendedWrite, IntendedTransfer, Outcome, ResolveRefusalReason, } from "./timeline/resolve.js";
26
+ export type { Mechanic, Resolver, Proposal, Expectation, AdjudicationInput, Adjudication, IntendedChange, IntendedWrite, IntendedTransfer, IntendedSet, Outcome, ResolveRefusalReason, } from "./timeline/resolve.js";
26
27
  export { createStateRenderer } from "./timeline/render.js";
27
28
  export type { RenderVocabulary, VocabularyEntry, StateRenderer, RenderedState, RenderedNoun, UnnamedFact, } from "./timeline/render.js";
28
29
  export { createTurnReader } from "./reader/turnReader.js";
@@ -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.7.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.7.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
@@ -218,3 +218,42 @@ export declare function transferConstrainedValue(params: {
218
218
  * discipline. `limit` applies after ordering, never before.
219
219
  */
220
220
  export declare function valueHistory(entityId: string, key: string, limit?: number): ValueTransition[];
221
+ /**
222
+ * One non-numeric column set, as `resolve()`'s `set` intent applies it (issue
223
+ * #32). A row, never a verdict: what the column held, what it holds now, and
224
+ * the fact the write opened -- `null` when the value did not move, because an
225
+ * unchanged value opens no new interval (projection.ts's update trigger).
226
+ */
227
+ export interface SetTransition {
228
+ entityId: string;
229
+ key: string;
230
+ previousValue: string | number | null;
231
+ newValue: string | number | null;
232
+ t: T;
233
+ factId: string | null;
234
+ }
235
+ /**
236
+ * Sets one live column of an entity's projected table to `value` (issue #32).
237
+ * The engine stores what it is handed and never learns what the column means;
238
+ * the projection triggers version it exactly as they version every other
239
+ * column write, so nothing here touches `facts` directly.
240
+ *
241
+ * The same choke point as numeric writes, and the same checks in the same
242
+ * voice: the entity and column are resolved against `PROJECTED_TABLES` /
243
+ * `liveColumns` (never a caller-supplied table name); the column that places
244
+ * the entity in its game is refused; a key carrying a numeric constraint is
245
+ * refused, because that value changes by a write where the constraint is
246
+ * evaluated; `resolve_only` asks the one question it always asks, whether an
247
+ * adjudication window is open; and a contradicted irreversible fact is
248
+ * translated into a typed `ConstraintViolationError` with the one hop
249
+ * attached, by `translateIrreversibleFailure`, never by reading the trigger's
250
+ * message.
251
+ *
252
+ * Not exported from the library: `resolve()` is its only caller, so a
253
+ * non-numeric consequence reaches storage through a resolution or not at all.
254
+ */
255
+ export declare function setProjectedValue(params: {
256
+ entityId: string;
257
+ key: string;
258
+ value: string | number | null;
259
+ }): SetTransition;
@@ -669,3 +669,81 @@ export function valueHistory(entityId, key, limit) {
669
669
  const limited = limit !== undefined ? ranked.slice(0, limit) : ranked;
670
670
  return limited.map((r) => r.transition);
671
671
  }
672
+ /** The numeric members of the constraint family. A key carrying one of them
673
+ * changes by a write (`writeConstrainedValue`), where the constraint is
674
+ * evaluated; a `set` would step round it. */
675
+ const NUMERIC_CONSTRAINT_KINDS = new Set(["monotonic", "bounded", "conserved"]);
676
+ function openFactId(db, entityId, key) {
677
+ const row = db
678
+ .prepare(`SELECT id FROM facts WHERE entity_id = ? AND key = ? AND valid_to_t IS NULL ORDER BY valid_from_t DESC, id DESC LIMIT 1`)
679
+ .get(entityId, key);
680
+ return row?.id ?? null;
681
+ }
682
+ /**
683
+ * Sets one live column of an entity's projected table to `value` (issue #32).
684
+ * The engine stores what it is handed and never learns what the column means;
685
+ * the projection triggers version it exactly as they version every other
686
+ * column write, so nothing here touches `facts` directly.
687
+ *
688
+ * The same choke point as numeric writes, and the same checks in the same
689
+ * voice: the entity and column are resolved against `PROJECTED_TABLES` /
690
+ * `liveColumns` (never a caller-supplied table name); the column that places
691
+ * the entity in its game is refused; a key carrying a numeric constraint is
692
+ * refused, because that value changes by a write where the constraint is
693
+ * evaluated; `resolve_only` asks the one question it always asks, whether an
694
+ * adjudication window is open; and a contradicted irreversible fact is
695
+ * translated into a typed `ConstraintViolationError` with the one hop
696
+ * attached, by `translateIrreversibleFailure`, never by reading the trigger's
697
+ * message.
698
+ *
699
+ * Not exported from the library: `resolve()` is its only caller, so a
700
+ * non-numeric consequence reaches storage through a resolution or not at all.
701
+ */
702
+ export function setProjectedValue(params) {
703
+ const resolved = resolveProjection(params.entityId, params.key);
704
+ const db = getDatabase();
705
+ const projected = PROJECTED_TABLES.find((p) => p.table === resolved.table);
706
+ if (projected && projected.gameIdColumn === params.key) {
707
+ throw new Error(`timeline: '${params.key}' places entity '${params.entityId}' in its game and cannot be set -- ` +
708
+ `an entity does not move between games`);
709
+ }
710
+ for (const constraint of constraintsFor(params.entityId, params.key)) {
711
+ if (NUMERIC_CONSTRAINT_KINDS.has(constraint.kind)) {
712
+ throw new ConstraintViolationError(constraint.kind, params.entityId, `Entity '${params.entityId}' is ${constraint.kind}-constrained for key '${params.key}', which a set does not ` +
713
+ `evaluate; this value changes by a write, where the constraint is checked.`);
714
+ }
715
+ if (constraint.kind === "resolve_only" && !adjudicationOpen()) {
716
+ throw new ConstraintViolationError("resolve_only", params.entityId, `Entity '${params.entityId}' is resolve_only-constrained for key '${params.key}'; direct writes are refused. ` +
717
+ `This value can only change through the adjudicating call that opens the resolution window.`);
718
+ }
719
+ }
720
+ const row = db.prepare(`SELECT ${resolved.key} AS value FROM ${resolved.table} WHERE id = ?`).get(resolved.entityId);
721
+ if (!row) {
722
+ throw new Error(`timeline: no live row in '${resolved.table}' for entity '${resolved.entityId}' -- ` +
723
+ `it may have been destroyed since it was last confirmed to exist`);
724
+ }
725
+ try {
726
+ return withTransaction(() => {
727
+ const before = openFactId(db, resolved.entityId, resolved.key);
728
+ db.prepare(`UPDATE ${resolved.table} SET ${resolved.key} = ? WHERE id = ?`).run(params.value, resolved.entityId);
729
+ const after = openFactId(db, resolved.entityId, resolved.key);
730
+ const story = currentStoryTime(resolved.gameId);
731
+ if (!story) {
732
+ throw new Error(`timeline: game '${resolved.gameId}' has no timeline clock -- a set has no t to attach to`);
733
+ }
734
+ return {
735
+ entityId: resolved.entityId,
736
+ key: resolved.key,
737
+ previousValue: row.value,
738
+ newValue: params.value,
739
+ t: story.t,
740
+ factId: after !== before ? after : null,
741
+ };
742
+ });
743
+ }
744
+ catch (err) {
745
+ if (params.value === null)
746
+ throw err;
747
+ translateIrreversibleFailure(db, [{ entityId: params.entityId, key: params.key, table: resolved.table, attemptedValue: params.value }], err);
748
+ }
749
+ }
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  import { type T } from "./t.js";
2
2
  import { type NarrationConstraint, type Contradiction } from "./narration.js";
3
- import { type ValueTransition } from "./constrained.js";
3
+ import { type ValueTransition, type SetTransition } from "./constrained.js";
4
4
  /**
5
5
  * The inbound half of authority (design §5.2a, GitHub issue #10): propose ->
6
6
  * adjudicate -> outcome. The engine enforces the PROTOCOL -- resolution
@@ -71,8 +71,9 @@ import { type ValueTransition } from "./constrained.js";
71
71
  * INSIDE it (adjudication.ts's own doc comment asks for exactly this
72
72
  * nesting, so the window row rolls back with the writes it
73
73
  * authorized). Every change goes through `writeConstrainedValue` /
74
- * `transferConstrainedValue` -- the one choke point (root CLAUDE.md
75
- * hard rule 7) -- never a direct write. A constraint violation
74
+ * `transferConstrainedValue` / `setProjectedValue` (issue #32, a
75
+ * non-numeric column) -- the one choke point (root CLAUDE.md hard
76
+ * rule 7) -- never a direct write. A constraint violation
76
77
  * anywhere in the list propagates out of the transaction untouched
77
78
  * (never caught and re-labelled here) and rolls back EVERY change the
78
79
  * transaction made, including ones that individually would have
@@ -169,7 +170,17 @@ export interface IntendedTransfer {
169
170
  maxValue: number | null;
170
171
  };
171
172
  }
172
- export type IntendedChange = IntendedWrite | IntendedTransfer;
173
+ /** One intended set of a non-numeric column on an entity's projected row
174
+ * (issue #32) -- a thing changing owner, a character changing place. The
175
+ * engine stores `value` and never learns what `key` means; see
176
+ * `setProjectedValue` (constrained.ts) for what it refuses. */
177
+ export interface IntendedSet {
178
+ kind: "set";
179
+ entityId: string;
180
+ key: string;
181
+ value: string | number | null;
182
+ }
183
+ export type IntendedChange = IntendedWrite | IntendedTransfer | IntendedSet;
173
184
  /**
174
185
  * What a mechanic returns. `changes` are intents, not writes -- `resolve()`
175
186
  * applies every one of them through the one choke point (step 5); the
@@ -201,6 +212,9 @@ export interface Outcome {
201
212
  t: T;
202
213
  result: Record<string, unknown>;
203
214
  transitions: ValueTransition[];
215
+ /** Every `set` this resolution applied, in order (issue #32). Kept apart
216
+ * from `transitions`, whose values are numbers. */
217
+ sets: SetTransition[];
204
218
  constraint: NarrationConstraint;
205
219
  eventId: string;
206
220
  }
@@ -3,7 +3,7 @@ import { getDatabase, withTransaction } from "../db/connection.js";
3
3
  import { currentStoryTime } from "./clock.js";
4
4
  import { narrationConstraintAt, contradictions } from "./narration.js";
5
5
  import { withAdjudicationOpen } from "./adjudication.js";
6
- import { writeConstrainedValue, transferConstrainedValue } from "./constrained.js";
6
+ import { writeConstrainedValue, transferConstrainedValue, setProjectedValue } from "./constrained.js";
7
7
  /**
8
8
  * Refused before dispatch, before any write, or (never, by construction --
9
9
  * see step 5 above) mid-apply. `reason` is the discriminant a caller
@@ -81,6 +81,9 @@ export function createResolver(params) {
81
81
  };
82
82
  }
83
83
  function applyChange(change) {
84
+ if (change.kind === "set") {
85
+ return setProjectedValue({ entityId: change.entityId, key: change.key, value: change.value });
86
+ }
84
87
  if (change.kind === "write") {
85
88
  return [
86
89
  writeConstrainedValue({
@@ -180,8 +183,13 @@ function resolveProposal(mechanicsByName, proposal) {
180
183
  // is never relabelled as a ResolveProtocolError).
181
184
  const applied = withTransaction(() => withAdjudicationOpen(gameId, () => {
182
185
  const transitions = [];
186
+ const sets = [];
183
187
  for (const change of changes) {
184
- transitions.push(...applyChange(change));
188
+ const applied = applyChange(change);
189
+ if (Array.isArray(applied))
190
+ transitions.push(...applied);
191
+ else
192
+ sets.push(applied);
185
193
  }
186
194
  // Re-read the clock AFTER every write has landed, inside this same
187
195
  // transaction -- a sequence-axis game advances its own t once per
@@ -206,7 +214,7 @@ function resolveProposal(mechanicsByName, proposal) {
206
214
  getDatabase()
207
215
  .prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, 'resolution.recorded', ?, ?)`)
208
216
  .run(eventId, gameId, postStory.t, adjudication.description ?? null, JSON.stringify(causes));
209
- return { transitions, eventId, postT: postStory.t };
217
+ return { transitions, sets, eventId, postT: postStory.t };
210
218
  }));
211
219
  // 7. The outcome's constraint, built AFTER the writes landed and the
212
220
  // transaction holding them has already committed -- reachable only from a
@@ -220,6 +228,7 @@ function resolveProposal(mechanicsByName, proposal) {
220
228
  t,
221
229
  result: adjudication.result ?? {},
222
230
  transitions: applied.transitions,
231
+ sets: applied.sets,
223
232
  constraint: postConstraint,
224
233
  eventId: applied.eventId,
225
234
  };
@@ -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.7.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",