run-dmcp 0.7.0 → 0.8.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,7 +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
+ export type { SetTransition, CreatedEntity, DestroyedEntity } from "./timeline/constrained.js";
18
18
  export { openingEventId } from "./timeline/provenance.js";
19
19
  export type { FactProvenance } from "./timeline/provenance.js";
20
20
  export { narrationConstraintAt, contradictions, NARRATION_CONSTRAINT_FORMAT_VERSION, } from "./timeline/narration.js";
@@ -23,7 +23,7 @@ export { writeConstrainedValue, transferConstrainedValue, valueHistory, } from "
23
23
  export type { ValueTransition } from "./timeline/constrained.js";
24
24
  export { ConstraintViolationError, constraintsFor, conservedConstraintFor } from "./timeline/registry.js";
25
25
  export { createResolver, ResolveProtocolError } from "./timeline/resolve.js";
26
- export type { Mechanic, Resolver, Proposal, Expectation, AdjudicationInput, Adjudication, IntendedChange, IntendedWrite, IntendedTransfer, IntendedSet, Outcome, ResolveRefusalReason, } from "./timeline/resolve.js";
26
+ export type { Mechanic, Resolver, Proposal, Expectation, AdjudicationInput, Adjudication, IntendedChange, IntendedWrite, IntendedTransfer, IntendedSet, IntendedCreate, IntendedDestroy, EntityRef, Outcome, ResolveRefusalReason, } from "./timeline/resolve.js";
27
27
  export { createStateRenderer } from "./timeline/render.js";
28
28
  export type { RenderVocabulary, VocabularyEntry, StateRenderer, RenderedState, RenderedNoun, UnnamedFact, } from "./timeline/render.js";
29
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.7.0";
9
+ export declare const SERVER_VERSION = "0.8.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.7.0";
48
+ export const SERVER_VERSION = "0.8.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
@@ -1,4 +1,5 @@
1
1
  import { type T } from "./t.js";
2
+ import type { EntityKind } from "./kinds.js";
2
3
  /**
3
4
  * The choke point (design §5.4 option (C), Phase 3 step 2): the ONE place a
4
5
  * constrained numeric fact key is written, and the ONE place `resources`'
@@ -257,3 +258,73 @@ export declare function setProjectedValue(params: {
257
258
  key: string;
258
259
  value: string | number | null;
259
260
  }): SetTransition;
261
+ /**
262
+ * One entity a resolution brought into existence (issue #34). A row, never
263
+ * a verdict: which kind, the id the engine allocated, where on the timeline
264
+ * it landed, and the `<kind>.created` event the projection trigger wrote --
265
+ * design §5.2c's one hop, recorded rather than derived later.
266
+ */
267
+ export interface CreatedEntity {
268
+ entityId: string;
269
+ entityKind: EntityKind;
270
+ t: T;
271
+ eventId: string | null;
272
+ }
273
+ /** One entity a resolution ended (issue #34): the `<kind>.destroyed` event
274
+ * and the `t` its facts were closed at. */
275
+ export interface DestroyedEntity {
276
+ entityId: string;
277
+ entityKind: EntityKind;
278
+ t: T;
279
+ eventId: string | null;
280
+ }
281
+ /**
282
+ * Inserts one live row into the projected table for `entityKind` (issue
283
+ * #34) -- the choke point's door for bringing an entity into existence
284
+ * inside a resolution. The projection insert trigger (projection.ts) does
285
+ * every piece of timeline work: the `entities` row, one open fact per
286
+ * non-NULL column, the `<kind>.created` event, the provenance stamp. Nothing
287
+ * here writes `facts` or `events` directly.
288
+ *
289
+ * What it refuses, before any write: a kind with no projected table; the
290
+ * `game` kind (a resolution belongs to a game and cannot create one); a
291
+ * caller-supplied `id` (the engine allocates it, because a mechanic has no
292
+ * database handle and could not know a free one); the column that places
293
+ * the entity in its game (filled from the proposal, never the mechanic);
294
+ * and any column the table's `liveColumns()` does not report. Column names
295
+ * are interpolated only after that check -- they are this codebase's own
296
+ * `pragma_table_info` vocabulary by then, never a caller's string. Values
297
+ * are bound. A column the table itself requires and the caller omitted is
298
+ * SQLite's refusal, not this function's: the engine never learns what a
299
+ * column means, including whether it is optional.
300
+ *
301
+ * Not exported from the library: `resolve()` is its only caller, so an
302
+ * entity comes into being through a resolution or through the tool layer,
303
+ * and there is no third way.
304
+ */
305
+ export declare function createProjectedEntity(params: {
306
+ gameId: string;
307
+ entityKind: EntityKind;
308
+ columns: Readonly<Record<string, string | number | null>>;
309
+ }): CreatedEntity;
310
+ /**
311
+ * Deletes one entity's live row (issue #34) -- the choke point's door for
312
+ * ending an entity inside a resolution. The projection delete trigger closes
313
+ * every open fact, sets `destroyed_at_t` and writes `<kind>.destroyed`.
314
+ *
315
+ * Refused, before any write: an entity that does not exist, or is already
316
+ * destroyed, naming it; the `game` kind; and -- the conservative reading
317
+ * #34 asked to be pinned -- an entity carrying an irreversible fact, as a
318
+ * typed `ConstraintViolationError` with the one hop attached: destroying the
319
+ * entity would close that fact, and a fact declared to hold thereafter is
320
+ * not ended by removing what it is about. A `resolve_only` fact does not
321
+ * refuse a destroy: closing an interval is not a write of a new value
322
+ * (src/db/schema.ts's own note on `timeline_facts_resolve_only`), and inside
323
+ * a resolution the window is open, which is all resolve_only asks.
324
+ *
325
+ * Not exported from the library, for the reason `createProjectedEntity`
326
+ * gives.
327
+ */
328
+ export declare function destroyProjectedEntity(params: {
329
+ entityId: string;
330
+ }): DestroyedEntity;
@@ -4,7 +4,7 @@ import { assertT, compareT } from "./t.js";
4
4
  import { currentStoryTime } from "./clock.js";
5
5
  import { PROJECTED_TABLES, liveColumns } from "./projection.js";
6
6
  import { constraintsFor, conservedConstraintFor, ConstraintViolationError, CONSERVED_SUM_EPSILON } from "./registry.js";
7
- import { irreversibleFactFor } from "./irreversible.js";
7
+ import { irreversibleFactFor, listIrreversibleFacts } from "./irreversible.js";
8
8
  import { adjudicationOpen } from "./adjudication.js";
9
9
  /**
10
10
  * A1: resolves `entityId` to the live table its `key` column lives in, and
@@ -747,3 +747,144 @@ export function setProjectedValue(params) {
747
747
  translateIrreversibleFailure(db, [{ entityId: params.entityId, key: params.key, table: resolved.table, attemptedValue: params.value }], err);
748
748
  }
749
749
  }
750
+ /** The projection event the trigger just wrote for `rowId` -- found by the
751
+ * trigger's own `row_id` token (projection.ts), never by guessing at `t`.
752
+ * `json_valid` guards the extraction for the reason `valueHistory` gives. */
753
+ function projectionEventId(db, kind, rowId) {
754
+ const row = db
755
+ .prepare(`SELECT id FROM events WHERE kind = ?
756
+ AND json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.row_id') = ?
757
+ ORDER BY rowid DESC LIMIT 1`)
758
+ .get(kind, rowId);
759
+ return row?.id ?? null;
760
+ }
761
+ /**
762
+ * Inserts one live row into the projected table for `entityKind` (issue
763
+ * #34) -- the choke point's door for bringing an entity into existence
764
+ * inside a resolution. The projection insert trigger (projection.ts) does
765
+ * every piece of timeline work: the `entities` row, one open fact per
766
+ * non-NULL column, the `<kind>.created` event, the provenance stamp. Nothing
767
+ * here writes `facts` or `events` directly.
768
+ *
769
+ * What it refuses, before any write: a kind with no projected table; the
770
+ * `game` kind (a resolution belongs to a game and cannot create one); a
771
+ * caller-supplied `id` (the engine allocates it, because a mechanic has no
772
+ * database handle and could not know a free one); the column that places
773
+ * the entity in its game (filled from the proposal, never the mechanic);
774
+ * and any column the table's `liveColumns()` does not report. Column names
775
+ * are interpolated only after that check -- they are this codebase's own
776
+ * `pragma_table_info` vocabulary by then, never a caller's string. Values
777
+ * are bound. A column the table itself requires and the caller omitted is
778
+ * SQLite's refusal, not this function's: the engine never learns what a
779
+ * column means, including whether it is optional.
780
+ *
781
+ * Not exported from the library: `resolve()` is its only caller, so an
782
+ * entity comes into being through a resolution or through the tool layer,
783
+ * and there is no third way.
784
+ */
785
+ export function createProjectedEntity(params) {
786
+ const projected = PROJECTED_TABLES.find((p) => p.kind === params.entityKind);
787
+ if (!projected) {
788
+ throw new Error(`timeline: '${String(params.entityKind)}' is not a projected entity kind -- a resolution can create only ` +
789
+ PROJECTED_TABLES.filter((p) => p.kind !== "game")
790
+ .map((p) => `'${p.kind}'`)
791
+ .join(", "));
792
+ }
793
+ if (projected.kind === "game") {
794
+ throw new Error(`timeline: a resolution belongs to a game and cannot create one`);
795
+ }
796
+ const db = getDatabase();
797
+ const cols = liveColumns(db, projected.table);
798
+ const keys = Object.keys(params.columns);
799
+ for (const key of keys) {
800
+ if (key === "id") {
801
+ throw new Error(`timeline: 'id' is allocated by the engine and cannot be supplied for a created ${projected.kind}`);
802
+ }
803
+ if (key === projected.gameIdColumn) {
804
+ throw new Error(`timeline: '${key}' places a created ${projected.kind} in its game and is filled from the proposal, never by the mechanic`);
805
+ }
806
+ if (!cols.includes(key)) {
807
+ throw new Error(`timeline: '${key}' is not a live column of '${projected.table}' -- a created ${projected.kind} has no fact key by that name`);
808
+ }
809
+ }
810
+ const id = uuidv4();
811
+ return withTransaction(() => {
812
+ const columnList = ["id", projected.gameIdColumn, ...keys];
813
+ db.prepare(`INSERT INTO ${projected.table} (${columnList.join(", ")}) VALUES (${columnList.map(() => "?").join(", ")})`).run(id, params.gameId, ...keys.map((key) => params.columns[key]));
814
+ const entity = db.prepare(`SELECT created_at_t FROM entities WHERE id = ?`).get(id);
815
+ if (!entity) {
816
+ // Cannot happen while the projection triggers are installed; not a
817
+ // case to assume silently forever.
818
+ throw new Error(`timeline: the insert trigger on '${projected.table}' recorded no entity for the row it just projected`);
819
+ }
820
+ assertT(entity.created_at_t);
821
+ return {
822
+ entityId: id,
823
+ entityKind: projected.kind,
824
+ t: entity.created_at_t,
825
+ eventId: projectionEventId(db, `${projected.kind}.created`, id),
826
+ };
827
+ });
828
+ }
829
+ /**
830
+ * Deletes one entity's live row (issue #34) -- the choke point's door for
831
+ * ending an entity inside a resolution. The projection delete trigger closes
832
+ * every open fact, sets `destroyed_at_t` and writes `<kind>.destroyed`.
833
+ *
834
+ * Refused, before any write: an entity that does not exist, or is already
835
+ * destroyed, naming it; the `game` kind; and -- the conservative reading
836
+ * #34 asked to be pinned -- an entity carrying an irreversible fact, as a
837
+ * typed `ConstraintViolationError` with the one hop attached: destroying the
838
+ * entity would close that fact, and a fact declared to hold thereafter is
839
+ * not ended by removing what it is about. A `resolve_only` fact does not
840
+ * refuse a destroy: closing an interval is not a write of a new value
841
+ * (src/db/schema.ts's own note on `timeline_facts_resolve_only`), and inside
842
+ * a resolution the window is open, which is all resolve_only asks.
843
+ *
844
+ * Not exported from the library, for the reason `createProjectedEntity`
845
+ * gives.
846
+ */
847
+ export function destroyProjectedEntity(params) {
848
+ const db = getDatabase();
849
+ const entity = db.prepare(`SELECT game_id, kind, destroyed_at_t FROM entities WHERE id = ?`).get(params.entityId);
850
+ if (!entity) {
851
+ throw new Error(`timeline: cannot destroy entity '${params.entityId}' -- it does not exist`);
852
+ }
853
+ if (entity.destroyed_at_t !== null) {
854
+ throw new Error(`timeline: entity '${params.entityId}' was already destroyed at t=${entity.destroyed_at_t}`);
855
+ }
856
+ const projected = PROJECTED_TABLES.find((p) => p.kind === entity.kind);
857
+ if (!projected) {
858
+ throw new Error(`timeline: entity '${params.entityId}' has kind '${entity.kind}', which has no projected table to delete from`);
859
+ }
860
+ if (projected.kind === "game") {
861
+ throw new Error(`timeline: a resolution belongs to a game and cannot destroy one`);
862
+ }
863
+ const live = db.prepare(`SELECT id FROM ${projected.table} WHERE id = ?`).get(params.entityId);
864
+ if (!live) {
865
+ throw new Error(`timeline: no live row in '${projected.table}' for entity '${params.entityId}' -- it may have been destroyed since it was last confirmed to exist`);
866
+ }
867
+ const [irreversible] = listIrreversibleFacts({ gameId: entity.game_id, entityId: params.entityId });
868
+ if (irreversible) {
869
+ throw new ConstraintViolationError("irreversible", params.entityId, `Entity '${params.entityId}' carries an irreversible fact for key '${irreversible.key}': value '${irreversible.value}' ` +
870
+ `holds as of t=${irreversible.validFromT}` +
871
+ (irreversible.openedByEventId !== null
872
+ ? ` (opened by event '${irreversible.openedByEventId}')`
873
+ : ` (no event is recorded for when this was opened)`) +
874
+ `. Destroying the entity would end that fact and is refused.`, irreversible);
875
+ }
876
+ return withTransaction(() => {
877
+ db.prepare(`DELETE FROM ${projected.table} WHERE id = ?`).run(params.entityId);
878
+ const after = db.prepare(`SELECT destroyed_at_t FROM entities WHERE id = ?`).get(params.entityId);
879
+ if (!after || after.destroyed_at_t === null) {
880
+ throw new Error(`timeline: the delete trigger on '${projected.table}' recorded no destruction for entity '${params.entityId}'`);
881
+ }
882
+ assertT(after.destroyed_at_t);
883
+ return {
884
+ entityId: params.entityId,
885
+ entityKind: projected.kind,
886
+ t: after.destroyed_at_t,
887
+ eventId: projectionEventId(db, `${projected.kind}.destroyed`, params.entityId),
888
+ };
889
+ });
890
+ }
@@ -1,6 +1,7 @@
1
1
  import { type T } from "./t.js";
2
2
  import { type NarrationConstraint, type Contradiction } from "./narration.js";
3
- import { type ValueTransition, type SetTransition } from "./constrained.js";
3
+ import type { EntityKind } from "./kinds.js";
4
+ import { type ValueTransition, type SetTransition, type CreatedEntity, type DestroyedEntity } from "./constrained.js";
4
5
  /**
5
6
  * The inbound half of authority (design §5.2a, GitHub issue #10): propose ->
6
7
  * adjudicate -> outcome. The engine enforces the PROTOCOL -- resolution
@@ -72,8 +73,16 @@ import { type ValueTransition, type SetTransition } from "./constrained.js";
72
73
  * nesting, so the window row rolls back with the writes it
73
74
  * authorized). Every change goes through `writeConstrainedValue` /
74
75
  * `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
+ * non-numeric column) / `createProjectedEntity` /
77
+ * `destroyProjectedEntity` (issue #34, an entity beginning or ending)
78
+ * -- the one choke point (root CLAUDE.md hard rule 7) -- never a
79
+ * direct write. A `create` is labelled with a caller-chosen `ref`,
80
+ * and a later leg of the same resolution may say `{ ref }` wherever
81
+ * it would say an entity id, because a mechanic has no database
82
+ * handle and cannot know the id the engine will allocate; every ref
83
+ * is checked against the legs before it, BEFORE the transaction
84
+ * opens, so a ref naming nothing refuses with no write attempted.
85
+ * A constraint violation
77
86
  * anywhere in the list propagates out of the transaction untouched
78
87
  * (never caught and re-labelled here) and rolls back EVERY change the
79
88
  * transaction made, including ones that individually would have
@@ -136,13 +145,24 @@ export interface AdjudicationInput {
136
145
  parameters: Record<string, unknown>;
137
146
  constraint: NarrationConstraint;
138
147
  }
148
+ /**
149
+ * An entity id, or the label of an entity created by an earlier `create`
150
+ * leg of the same resolution (issue #34). A mechanic cannot know the id the
151
+ * engine allocates -- it has no database handle, which is the whole
152
+ * mechanism of "every write goes through the audited path" -- so it names
153
+ * the new entity by the `ref` it chose, and `resolve()` substitutes the
154
+ * real id when it applies the leg.
155
+ */
156
+ export type EntityRef = string | {
157
+ ref: string;
158
+ };
139
159
  /** One intended write to a single fact key -- the generic shape
140
160
  * `writeConstrainedValue` (constrained.ts) already takes, carried here so a
141
161
  * mechanic can express "change this value" without ever calling that
142
162
  * function itself. */
143
163
  export interface IntendedWrite {
144
164
  kind: "write";
145
- entityId: string;
165
+ entityId: EntityRef;
146
166
  key: string;
147
167
  mode: "delta" | "set";
148
168
  value: number;
@@ -156,8 +176,8 @@ export interface IntendedWrite {
156
176
  * shape `transferConstrainedValue` (constrained.ts) already takes. */
157
177
  export interface IntendedTransfer {
158
178
  kind: "transfer";
159
- fromEntityId: string;
160
- toEntityId: string;
179
+ fromEntityId: EntityRef;
180
+ toEntityId: EntityRef;
161
181
  key: string;
162
182
  amount: number;
163
183
  reason?: string | null;
@@ -176,11 +196,37 @@ export interface IntendedTransfer {
176
196
  * `setProjectedValue` (constrained.ts) for what it refuses. */
177
197
  export interface IntendedSet {
178
198
  kind: "set";
179
- entityId: string;
199
+ entityId: EntityRef;
180
200
  key: string;
181
- value: string | number | null;
201
+ /** `{ ref }` names an entity created earlier in this resolution -- the
202
+ * new thing becoming this entity's owner, say. */
203
+ value: string | number | null | {
204
+ ref: string;
205
+ };
206
+ }
207
+ /** One intended entity coming into existence (issue #34): a row in the
208
+ * projected table for `entityKind`, with `columns` restricted to that
209
+ * table's live columns and the game column filled by the engine from the
210
+ * proposal. `ref` is the label later legs of this same resolution use for
211
+ * it; the engine allocates the id and reports it in `Outcome.created`.
212
+ * The engine never learns what the entity is for or what it was made
213
+ * from -- "derived from" is the caller's to record. */
214
+ export interface IntendedCreate {
215
+ kind: "create";
216
+ ref: string;
217
+ entityKind: EntityKind;
218
+ columns: Readonly<Record<string, string | number | null | {
219
+ ref: string;
220
+ }>>;
221
+ }
222
+ /** One intended entity ending (issue #34): its live row deleted, its facts
223
+ * closed by the projection trigger. See `destroyProjectedEntity`
224
+ * (constrained.ts) for what it refuses. */
225
+ export interface IntendedDestroy {
226
+ kind: "destroy";
227
+ entityId: EntityRef;
182
228
  }
183
- export type IntendedChange = IntendedWrite | IntendedTransfer | IntendedSet;
229
+ export type IntendedChange = IntendedWrite | IntendedTransfer | IntendedSet | IntendedCreate | IntendedDestroy;
184
230
  /**
185
231
  * What a mechanic returns. `changes` are intents, not writes -- `resolve()`
186
232
  * applies every one of them through the one choke point (step 5); the
@@ -215,6 +261,15 @@ export interface Outcome {
215
261
  /** Every `set` this resolution applied, in order (issue #32). Kept apart
216
262
  * from `transitions`, whose values are numbers. */
217
263
  sets: SetTransition[];
264
+ /** Every entity this resolution created, in order, each with the `ref`
265
+ * the mechanic labelled it with and the id the engine allocated (issue
266
+ * #34). Kept apart from `transitions` and `sets`, so existing callers are
267
+ * unchanged. */
268
+ created: (CreatedEntity & {
269
+ ref: string;
270
+ })[];
271
+ /** Every entity this resolution destroyed, in order (issue #34). */
272
+ destroyed: DestroyedEntity[];
218
273
  constraint: NarrationConstraint;
219
274
  eventId: string;
220
275
  }
@@ -232,7 +287,13 @@ export interface Mechanic {
232
287
  * under -- never a judgement about the proposal, the mechanic, or the
233
288
  * world (see the module doc comment's "records decisions, does not make
234
289
  * them" paragraph). */
235
- export type ResolveRefusalReason = "unknown-mechanic" | "no-clock" | "expectation-contradicted";
290
+ export type ResolveRefusalReason = "unknown-mechanic" | "no-clock" | "expectation-contradicted"
291
+ /** A leg said `{ ref }` and no earlier `create` leg of the same
292
+ * resolution defined that ref (issue #34). Refused before any write. */
293
+ | "unresolved-ref"
294
+ /** Two `create` legs of one resolution chose the same `ref` (issue #34).
295
+ * Refused before any write. */
296
+ | "duplicate-ref";
236
297
  /**
237
298
  * Refused before dispatch, before any write, or (never, by construction --
238
299
  * see step 5 above) mid-apply. `reason` is the discriminant a caller
@@ -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, setProjectedValue } from "./constrained.js";
6
+ import { writeConstrainedValue, transferConstrainedValue, setProjectedValue, createProjectedEntity, destroyProjectedEntity, } 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
@@ -80,33 +80,106 @@ export function createResolver(params) {
80
80
  },
81
81
  };
82
82
  }
83
- function applyChange(change) {
83
+ /** Every `{ ref }` a change carries, in the order it is read (issue #34) --
84
+ * the one place the shape of each intent kind's ref-bearing fields is
85
+ * known, shared by the pre-transaction check and the apply step. */
86
+ function refsUsedBy(change) {
87
+ const refOf = (value) => (typeof value === "object" && value !== null && "ref" in value ? [String(value.ref)] : []);
88
+ switch (change.kind) {
89
+ case "write":
90
+ return refOf(change.entityId);
91
+ case "transfer":
92
+ return [...refOf(change.fromEntityId), ...refOf(change.toEntityId)];
93
+ case "set":
94
+ return [...refOf(change.entityId), ...refOf(change.value)];
95
+ case "create":
96
+ return Object.values(change.columns).flatMap(refOf);
97
+ case "destroy":
98
+ return refOf(change.entityId);
99
+ default:
100
+ return [];
101
+ }
102
+ }
103
+ /**
104
+ * Step 5's precondition (issue #34): every `{ ref }` names a `create` leg
105
+ * EARLIER in the list, and no two creates share a ref. Checked over the
106
+ * intent list alone -- a structural property of what the mechanic returned,
107
+ * decidable with no database at all -- so a bad ref refuses with no
108
+ * transaction opened and no write attempted, in the same voice as every
109
+ * other protocol refusal.
110
+ */
111
+ function assertRefsResolvable(mechanicName, changes) {
112
+ const defined = new Set();
113
+ changes.forEach((change, index) => {
114
+ for (const ref of refsUsedBy(change)) {
115
+ if (!defined.has(ref)) {
116
+ throw new ResolveProtocolError("unresolved-ref", `resolve: leg ${index + 1} of mechanic '${mechanicName}' names ref '${ref}', and no earlier 'create' leg of this ` +
117
+ `resolution defines it; refused before any write. A ref may only name an entity created by a leg before the one that uses it.`);
118
+ }
119
+ }
120
+ if (change.kind === "create") {
121
+ if (defined.has(change.ref)) {
122
+ throw new ResolveProtocolError("duplicate-ref", `resolve: mechanic '${mechanicName}' creates two entities under the ref '${change.ref}'; refused before any write. ` +
123
+ `A ref is unique within one resolution.`);
124
+ }
125
+ defined.add(change.ref);
126
+ }
127
+ });
128
+ }
129
+ function deref(value, refs) {
130
+ if (typeof value === "string")
131
+ return value;
132
+ const id = refs.get(value.ref);
133
+ if (id === undefined) {
134
+ // Unreachable after assertRefsResolvable; guarded so a future caller of
135
+ // this function alone still fails loudly.
136
+ throw new ResolveProtocolError("unresolved-ref", `resolve: ref '${value.ref}' names no entity created in this resolution`);
137
+ }
138
+ return id;
139
+ }
140
+ function derefValue(value, refs) {
141
+ return typeof value === "object" && value !== null ? deref(value, refs) : value;
142
+ }
143
+ function applyChange(change, gameId, refs) {
84
144
  if (change.kind === "set") {
85
- return setProjectedValue({ entityId: change.entityId, key: change.key, value: change.value });
145
+ return { set: setProjectedValue({ entityId: deref(change.entityId, refs), key: change.key, value: derefValue(change.value, refs) }) };
86
146
  }
87
147
  if (change.kind === "write") {
88
- return [
89
- writeConstrainedValue({
90
- entityId: change.entityId,
91
- key: change.key,
92
- mode: change.mode,
93
- value: change.value,
94
- reason: change.reason,
95
- bounds: change.bounds,
96
- }),
97
- ];
148
+ return {
149
+ transitions: [
150
+ writeConstrainedValue({
151
+ entityId: deref(change.entityId, refs),
152
+ key: change.key,
153
+ mode: change.mode,
154
+ value: change.value,
155
+ reason: change.reason,
156
+ bounds: change.bounds,
157
+ }),
158
+ ],
159
+ };
98
160
  }
99
161
  if (change.kind === "transfer") {
100
162
  const { from, to } = transferConstrainedValue({
101
- fromEntityId: change.fromEntityId,
102
- toEntityId: change.toEntityId,
163
+ fromEntityId: deref(change.fromEntityId, refs),
164
+ toEntityId: deref(change.toEntityId, refs),
103
165
  key: change.key,
104
166
  amount: change.amount,
105
167
  reason: change.reason,
106
168
  fromBounds: change.fromBounds,
107
169
  toBounds: change.toBounds,
108
170
  });
109
- return [from, to];
171
+ return { transitions: [from, to] };
172
+ }
173
+ if (change.kind === "create") {
174
+ const columns = {};
175
+ for (const [key, value] of Object.entries(change.columns))
176
+ columns[key] = derefValue(value, refs);
177
+ const created = createProjectedEntity({ gameId, entityKind: change.entityKind, columns });
178
+ refs.set(change.ref, created.entityId);
179
+ return { created: { ...created, ref: change.ref } };
180
+ }
181
+ if (change.kind === "destroy") {
182
+ return { destroyed: destroyProjectedEntity({ entityId: deref(change.entityId, refs) }) };
110
183
  }
111
184
  // Unreachable through the exported types (`IntendedChange` is an
112
185
  // exhaustive discriminated union), but a mechanic is caller-supplied code
@@ -173,6 +246,8 @@ function resolveProposal(mechanicsByName, proposal) {
173
246
  });
174
247
  const resolutionId = uuidv4();
175
248
  const changes = adjudication.changes ?? [];
249
+ // 5's precondition: every ref resolves, before the transaction opens.
250
+ assertRefsResolvable(mechanicName, changes);
176
251
  // 5 & 6. Apply every intended change through the one choke point, and
177
252
  // record one event -- both inside ONE transaction with the adjudication
178
253
  // window nested inside it (adjudication.ts's own doc comment asks for
@@ -184,12 +259,19 @@ function resolveProposal(mechanicsByName, proposal) {
184
259
  const applied = withTransaction(() => withAdjudicationOpen(gameId, () => {
185
260
  const transitions = [];
186
261
  const sets = [];
262
+ const created = [];
263
+ const destroyed = [];
264
+ const refs = new Map();
187
265
  for (const change of changes) {
188
- const applied = applyChange(change);
189
- if (Array.isArray(applied))
190
- transitions.push(...applied);
266
+ const applied = applyChange(change, gameId, refs);
267
+ if ("transitions" in applied)
268
+ transitions.push(...applied.transitions);
269
+ else if ("set" in applied)
270
+ sets.push(applied.set);
271
+ else if ("created" in applied)
272
+ created.push(applied.created);
191
273
  else
192
- sets.push(applied);
274
+ destroyed.push(applied.destroyed);
193
275
  }
194
276
  // Re-read the clock AFTER every write has landed, inside this same
195
277
  // transaction -- a sequence-axis game advances its own t once per
@@ -214,7 +296,7 @@ function resolveProposal(mechanicsByName, proposal) {
214
296
  getDatabase()
215
297
  .prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, 'resolution.recorded', ?, ?)`)
216
298
  .run(eventId, gameId, postStory.t, adjudication.description ?? null, JSON.stringify(causes));
217
- return { transitions, sets, eventId, postT: postStory.t };
299
+ return { transitions, sets, created, destroyed, eventId, postT: postStory.t };
218
300
  }));
219
301
  // 7. The outcome's constraint, built AFTER the writes landed and the
220
302
  // transaction holding them has already committed -- reachable only from a
@@ -229,6 +311,8 @@ function resolveProposal(mechanicsByName, proposal) {
229
311
  result: adjudication.result ?? {},
230
312
  transitions: applied.transitions,
231
313
  sets: applied.sets,
314
+ created: applied.created,
315
+ destroyed: applied.destroyed,
232
316
  constraint: postConstraint,
233
317
  eventId: applied.eventId,
234
318
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run-dmcp",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",