run-dmcp 0.4.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.
@@ -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(`
@@ -1,24 +1,53 @@
1
1
  import { z } from "zod";
2
2
  /**
3
3
  * Common input length limits for security and performance.
4
- * These prevent DoS attacks via extremely large inputs.
4
+ *
5
+ * These prevent DoS attacks via extremely large inputs -- and they are also
6
+ * the engine's PUBLISHED CONTRACT. A `.max()` here lands in the tool's
7
+ * `input_schema` and `tools/list` hands it to every client before the first
8
+ * call, so a consumer can read what a field takes instead of hard-coding a
9
+ * copy of these numbers. One does. Leaving a string parameter unbounded is
10
+ * therefore not a missing safety net but an active statement that the field
11
+ * takes anything; `src/__tests__/schemaBounds.test.ts` fails on any that is
12
+ * (issue #29).
5
13
  */
6
14
  export declare const LIMITS: {
7
15
  readonly NAME_MAX: 200;
8
16
  readonly DESCRIPTION_MAX: 5000;
9
17
  readonly CONTENT_MAX: 50000;
10
18
  readonly NARRATIVE_MAX: 200000;
19
+ readonly EMBEDDED_DATA_MAX: 2000000;
11
20
  readonly ARRAY_MAX: 100;
12
21
  readonly MAX_DEPTH: 10;
13
22
  };
14
23
  /**
15
- * Pre-built Zod schemas with length limits
24
+ * Pre-built Zod schemas with length limits.
25
+ *
26
+ * EVERY PROPERTY HERE IS A GETTER, AND HANDS BACK A FRESH INSTANCE.
27
+ *
28
+ * That is not a style: `zod-to-json-schema` (which the MCP SDK runs over
29
+ * every `inputSchema` on its way to `tools/list`) de-duplicates by object
30
+ * IDENTITY. Reuse one instance twice inside a single tool and the second
31
+ * occurrence is published as
32
+ *
33
+ * { "$ref": "#/properties/imageGen/properties/subject/properties/..." }
34
+ *
35
+ * -- a pointer into a sibling property, in place of the declaration a
36
+ * consumer came to read. Sharing these as plain constants put 648 of those
37
+ * into the contract, where there had been none; a fresh instance per access
38
+ * puts the declaration back at every site. `src/__tests__/schemaBounds.test.ts`
39
+ * fails on any `$ref` in a published schema, which is what caught it.
40
+ *
41
+ * The cost is a schema object built per access. They are read at server
42
+ * construction, not per call.
16
43
  */
17
44
  export declare const validatedSchemas: {
18
45
  readonly name: z.ZodString;
46
+ readonly token: z.ZodString;
19
47
  readonly description: z.ZodString;
20
48
  readonly content: z.ZodString;
21
49
  readonly narrative: z.ZodString;
50
+ readonly embeddedData: z.ZodString;
22
51
  readonly id: z.ZodString;
23
52
  readonly tag: z.ZodString;
24
53
  readonly stringArray: z.ZodArray<z.ZodString, "many">;
@@ -1,7 +1,15 @@
1
1
  import { z } from "zod";
2
2
  /**
3
3
  * Common input length limits for security and performance.
4
- * These prevent DoS attacks via extremely large inputs.
4
+ *
5
+ * These prevent DoS attacks via extremely large inputs -- and they are also
6
+ * the engine's PUBLISHED CONTRACT. A `.max()` here lands in the tool's
7
+ * `input_schema` and `tools/list` hands it to every client before the first
8
+ * call, so a consumer can read what a field takes instead of hard-coding a
9
+ * copy of these numbers. One does. Leaving a string parameter unbounded is
10
+ * therefore not a missing safety net but an active statement that the field
11
+ * takes anything; `src/__tests__/schemaBounds.test.ts` fails on any that is
12
+ * (issue #29).
5
13
  */
6
14
  export const LIMITS = {
7
15
  // Short text fields (names, titles)
@@ -12,31 +20,80 @@ export const LIMITS = {
12
20
  CONTENT_MAX: 50000,
13
21
  // Very long text fields (story exports, narrative)
14
22
  NARRATIVE_MAX: 200000,
23
+ // Inline binary carried as a data URI or bare base64 -- an image, not prose.
24
+ // Deliberately generous rather than a prose tier: the fields that take one
25
+ // (`imageGen.generations[].base64`) accepted anything before they were
26
+ // bounded, and a real inline PNG is hundreds of kilobytes.
27
+ EMBEDDED_DATA_MAX: 2000000,
15
28
  // Array limits
16
29
  ARRAY_MAX: 100,
17
30
  // JSON object depth limit (for nested structures)
18
31
  MAX_DEPTH: 10,
19
32
  };
20
33
  /**
21
- * Pre-built Zod schemas with length limits
34
+ * Pre-built Zod schemas with length limits.
35
+ *
36
+ * EVERY PROPERTY HERE IS A GETTER, AND HANDS BACK A FRESH INSTANCE.
37
+ *
38
+ * That is not a style: `zod-to-json-schema` (which the MCP SDK runs over
39
+ * every `inputSchema` on its way to `tools/list`) de-duplicates by object
40
+ * IDENTITY. Reuse one instance twice inside a single tool and the second
41
+ * occurrence is published as
42
+ *
43
+ * { "$ref": "#/properties/imageGen/properties/subject/properties/..." }
44
+ *
45
+ * -- a pointer into a sibling property, in place of the declaration a
46
+ * consumer came to read. Sharing these as plain constants put 648 of those
47
+ * into the contract, where there had been none; a fresh instance per access
48
+ * puts the declaration back at every site. `src/__tests__/schemaBounds.test.ts`
49
+ * fails on any `$ref` in a published schema, which is what caught it.
50
+ *
51
+ * The cost is a schema object built per access. They are read at server
52
+ * construction, not per call.
22
53
  */
23
54
  export const validatedSchemas = {
24
55
  // Name fields (character names, location names, etc.)
25
- name: z.string().min(1).max(LIMITS.NAME_MAX),
56
+ get name() {
57
+ return z.string().min(1).max(LIMITS.NAME_MAX);
58
+ },
59
+ // Short free text with a ceiling and NO floor -- a colour, a direction, an
60
+ // aspect ratio, a sampler. Same length as `name`, without `.min(1)`: bounding
61
+ // a field that accepts "" today must not also start rejecting it.
62
+ get token() {
63
+ return z.string().max(LIMITS.NAME_MAX);
64
+ },
26
65
  // Description fields
27
- description: z.string().max(LIMITS.DESCRIPTION_MAX),
66
+ get description() {
67
+ return z.string().max(LIMITS.DESCRIPTION_MAX);
68
+ },
28
69
  // Content fields (notes, large text)
29
- content: z.string().max(LIMITS.CONTENT_MAX),
70
+ get content() {
71
+ return z.string().max(LIMITS.CONTENT_MAX);
72
+ },
30
73
  // Narrative content (very large text allowed)
31
- narrative: z.string().max(LIMITS.NARRATIVE_MAX),
74
+ get narrative() {
75
+ return z.string().max(LIMITS.NARRATIVE_MAX);
76
+ },
77
+ // Inline binary carried as base64 or a data URI -- an image, not prose.
78
+ get embeddedData() {
79
+ return z.string().max(LIMITS.EMBEDDED_DATA_MAX);
80
+ },
32
81
  // ID fields (UUIDs are 36 characters)
33
- id: z.string().max(100),
82
+ get id() {
83
+ return z.string().max(100);
84
+ },
34
85
  // Tag/category strings
35
- tag: z.string().min(1).max(100),
86
+ get tag() {
87
+ return z.string().min(1).max(100);
88
+ },
36
89
  // Array of strings with limits
37
- stringArray: z.array(z.string().max(LIMITS.NAME_MAX)).max(LIMITS.ARRAY_MAX),
90
+ get stringArray() {
91
+ return z.array(z.string().max(LIMITS.NAME_MAX)).max(LIMITS.ARRAY_MAX);
92
+ },
38
93
  // Array of tags
39
- tagArray: z.array(z.string().min(1).max(100)).max(LIMITS.ARRAY_MAX),
94
+ get tagArray() {
95
+ return z.array(z.string().min(1).max(100)).max(LIMITS.ARRAY_MAX);
96
+ },
40
97
  };
41
98
  /**
42
99
  * Helper to create a bounded string schema
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run-dmcp",
3
- "version": "0.4.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",