@principal-ai/subsystems-core 0.48.1 → 0.49.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.
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "trails": {
34
34
  "type": "array",
35
- "description": "Ordered runtime trails (one per named behavior). Each step names from/to/mechanism and the concrete file:line where that seam fires.",
35
+ "description": "Ordered runtime trails (one per named behavior). Each step names from/to/mechanism and the concrete file where that seam fires; the seam's source span is captured by verification.",
36
36
  "items": {
37
37
  "$ref": "#/$defs/trail"
38
38
  }
@@ -231,9 +231,9 @@
231
231
  "$ref": "#/$defs/declToken"
232
232
  }
233
233
  },
234
- "declarationRef": {
235
- "$ref": "#/$defs/declarationRef",
236
- "description": "Anchored declaration location (start line + line hash). Usually populated by verify tooling, not hand-authored. Distinct from `declaration`, which is the structured shape."
234
+ "declarationSpan": {
235
+ "$ref": "#/$defs/sourceSpan",
236
+ "description": "The construct's source span (start/end/content hash) — its anchored identity. Usually populated by verify tooling, not hand-authored. Distinct from `declaration`, which is the structured shape."
237
237
  }
238
238
  }
239
239
  },
@@ -244,7 +244,6 @@
244
244
  "to",
245
245
  "mechanism",
246
246
  "file",
247
- "line",
248
247
  "purl",
249
248
  "symbol"
250
249
  ],
@@ -269,10 +268,9 @@
269
268
  "pattern": "^(?!.*(^|/)node_modules(/|$)).*$",
270
269
  "description": "Repo-root-relative path where the seam fires for this trail."
271
270
  },
272
- "line": {
273
- "type": "integer",
274
- "minimum": 1,
275
- "description": "1-based line of the site within `file`."
271
+ "span": {
272
+ "$ref": "#/$defs/sourceSpan",
273
+ "description": "The seam's source span — the call-site range and its content hash. The trail-step counterpart of a component's declarationSpan; captured by verify tooling. `file` + `symbol` locate the frame."
276
274
  },
277
275
  "purl": {
278
276
  "type": "string",
@@ -352,36 +350,30 @@
352
350
  }
353
351
  }
354
352
  },
355
- "declarationRef": {
353
+ "sourceSpan": {
356
354
  "type": "object",
355
+ "description": "A content-addressed range of source — the model's identity primitive. startLine/endLine are 1-based inclusive lines within the owner's `file`; contentHash is the hash of the normalized content across that range. `file` / `purl` live on the owner, so a span is a pure range + fingerprint.",
357
356
  "required": [
358
- "file",
359
357
  "startLine",
360
- "lineHash",
361
- "capturedAt"
358
+ "endLine",
359
+ "contentHash"
362
360
  ],
363
361
  "additionalProperties": false,
364
362
  "properties": {
365
- "file": {
366
- "type": "string",
367
- "description": "Repo-root-relative path (matches component.file)."
368
- },
369
363
  "startLine": {
370
364
  "type": "integer",
371
365
  "minimum": 1,
372
- "description": "1-based declaration start line."
366
+ "description": "1-based first line of the construct/seam within the owner's file."
373
367
  },
374
- "lineHash": {
375
- "type": "string",
376
- "description": "Hash of the normalized declaration line at capture time (drift detection)."
377
- },
378
- "graphifyNodeId": {
379
- "type": "string",
380
- "description": "Graphify node id when captured from an exact anchor."
368
+ "endLine": {
369
+ "type": "integer",
370
+ "minimum": 1,
371
+ "description": "1-based last line, inclusive. Always >= startLine (cross-field rule enforced by validation)."
381
372
  },
382
- "capturedAt": {
373
+ "contentHash": {
383
374
  "type": "string",
384
- "description": "ISO timestamp when this ref was recorded."
375
+ "minLength": 1,
376
+ "description": "SHA-256 of the normalized content across [startLine, endLine], truncated to 32 hex chars. The span's identity."
385
377
  }
386
378
  }
387
379
  },
@@ -3,11 +3,11 @@
3
3
  * agrees on. Browser-safe (no `node:*`); the file-per-topic store that reads and
4
4
  * writes these lives in `./node` (see `topicStore.ts`).
5
5
  *
6
- * A **topic** is a curated bundle of trails on one subject. Two distinct named
7
- * shapes, split by id-namespace / ownership, with an explicit translation
8
- * between them — NOT one shared shape. They share most field names, but `id`
9
- * and `trailIds` live in different namespaces (local vs server), so passing one
10
- * where the other is expected corrupts the trail foreign-keys.
6
+ * A **topic** is a locally authored subject brief that may be published. Two
7
+ * distinct named shapes, split by id-namespace / ownership, with an explicit
8
+ * translation between them — NOT one shared shape. Their `id` fields live in
9
+ * different namespaces (local vs server), so passing one where the other is
10
+ * expected corrupts topic identity.
11
11
  *
12
12
  * - {@link DraftTopic} — locally-authored, what desktop persists + edits.
13
13
  * - {@link PublishedTopic} — server-authoritative, what the server returns and
@@ -15,10 +15,8 @@
15
15
  * - {@link publishedFromDraft} — the Draft -> Published projection (one-way;
16
16
  * there is no reverse hydration).
17
17
  *
18
- * The shapes mirror the over-the-wire form used by the sharing API (web-ade) so
19
- * a published topic and a locally stored one stay interchangeable on read. All
20
- * timestamps are ISO 8601 strings because this contract crosses the desktop/web
21
- * boundary.
18
+ * All timestamps are ISO 8601 strings because this contract crosses the
19
+ * desktop/web boundary.
22
20
  */
23
21
 
24
22
  /**
@@ -40,13 +38,7 @@ export interface TopicStatus {
40
38
  * `new-thought`, so legacy topics and renamed values need no migration.
41
39
  */
42
40
  state:
43
- | 'new-thought'
44
- | 'working'
45
- | 'paused'
46
- | 'waiting'
47
- | 'done-for-now'
48
- | 'deprecated'
49
- | 'abandoned';
41
+ 'new-thought' | 'working' | 'paused' | 'waiting' | 'done-for-now' | 'deprecated' | 'abandoned';
50
42
  /** Free-form text shown in place of the default label for the state. */
51
43
  label?: string;
52
44
  /** External blocker description. Meaningful when `state` is `waiting`. */
@@ -57,7 +49,7 @@ export interface TopicStatus {
57
49
  until?: string;
58
50
  /** Structured pointer to the thing being waited on, for automations. */
59
51
  ref?: {
60
- kind: 'url' | 'pr' | 'issue' | 'topic' | 'trail';
52
+ kind: 'url' | 'pr' | 'issue' | 'topic';
61
53
  /** The address/id of the referenced thing. */
62
54
  value: string;
63
55
  /** Optional human label for display. */
@@ -122,8 +114,6 @@ export interface DraftTopic {
122
114
  title: string;
123
115
  /** Optional markdown body. */
124
116
  description?: string;
125
- /** Ordered list of LOCAL trail ids — foreign keys into the local trail store. */
126
- trailIds: string[];
127
117
  /**
128
118
  * Repositories this topic is about, as PURL strings (e.g.
129
119
  * `pkg:github/owner/repo`). The topic's own source of truth for "which repos"
@@ -160,8 +150,6 @@ export interface PublishedTopic {
160
150
  title: string;
161
151
  /** Markdown body — required on the server. */
162
152
  description: string;
163
- /** Ordered list of REMOTE trail ids. */
164
- trailIds: string[];
165
153
  /**
166
154
  * Repositories this topic is about, as PURL strings (e.g.
167
155
  * `pkg:github/owner/repo`). Travels with the topic across the wire so a
@@ -184,13 +172,10 @@ export interface PublishedTopic {
184
172
  * Inputs the {@link DraftTopic} cannot supply on its own — the namespace
185
173
  * crossing and the fields publish must enforce. The caller resolves these
186
174
  * before projecting:
187
- * - `trailIds`: LOCAL trail ids remapped to REMOTE ids (web-ade references
188
- * trails by their server id; see desktop's `resolveRemoteTrailIds`).
189
175
  * - `createdBy`: required on the server; supplied here if the draft lacks it.
190
176
  * - `visibility`: a published concept the draft never carries.
191
177
  */
192
178
  export interface PublishProjection {
193
- trailIds: string[];
194
179
  createdBy: TopicCreator;
195
180
  visibility: 'private' | 'public';
196
181
  }
@@ -199,8 +184,8 @@ export interface PublishProjection {
199
184
  * Project a {@link DraftTopic} into a {@link PublishedTopic} — the one-way
200
185
  * Draft -> Published translation. It does NOT perform any I/O (no id minting,
201
186
  * no asset upload, no network): the caller resolves the namespace-crossing and
202
- * required fields into {@link PublishProjection} first (remapping trail ids,
203
- * uploading assets, choosing visibility), and this enforces the shape.
187
+ * required fields into {@link PublishProjection} first (uploading assets,
188
+ * choosing visibility), and this enforces the shape.
204
189
  *
205
190
  * One-way by design: there is no reverse `PublishedTopic -> DraftTopic`
206
191
  * hydration. A published topic's edits write through to the server; the local
@@ -212,19 +197,16 @@ export interface PublishProjection {
212
197
  */
213
198
  export function publishedFromDraft(
214
199
  draft: DraftTopic,
215
- projection: PublishProjection,
200
+ projection: PublishProjection
216
201
  ): PublishedTopic {
217
202
  const description = (draft.description ?? '').trim();
218
203
  if (description.length === 0) {
219
- throw new Error(
220
- `Topic ${draft.id} has no description; a description is required to publish.`,
221
- );
204
+ throw new Error(`Topic ${draft.id} has no description; a description is required to publish.`);
222
205
  }
223
206
  return {
224
207
  id: draft.id,
225
208
  title: draft.title,
226
209
  description,
227
- trailIds: projection.trailIds,
228
210
  ...(draft.repos !== undefined ? { repos: draft.repos } : {}),
229
211
  createdAt: draft.createdAt,
230
212
  updatedAt: draft.updatedAt,
@@ -28,7 +28,7 @@ afterEach(() => {
28
28
  describe('CRUD', () => {
29
29
  test('create generates an id, persists a file, and reads back', async () => {
30
30
  const store = makeStore();
31
- const created = await store.createTopic({ title: 'Auth flow', trailIds: [] });
31
+ const created = await store.createTopic({ title: 'Auth flow' });
32
32
  expect(created.id).toMatch(/^topic-/);
33
33
  expect(existsSync(join(topicsDir, `${created.id}.json`))).toBe(true);
34
34
 
@@ -38,20 +38,22 @@ describe('CRUD', () => {
38
38
 
39
39
  test('create honors an explicit id and rejects duplicates', async () => {
40
40
  const store = makeStore();
41
- await store.createTopic({ id: 'topic-x', title: 'A', trailIds: [] });
42
- await expect(
43
- store.createTopic({ id: 'topic-x', title: 'B', trailIds: [] }),
44
- ).rejects.toThrow(/already exists/);
41
+ await store.createTopic({ id: 'topic-x', title: 'A' });
42
+ await expect(store.createTopic({ id: 'topic-x', title: 'B' })).rejects.toThrow(
43
+ /already exists/
44
+ );
45
45
  });
46
46
 
47
- test('update changes fields but never id/createdAt/trailIds', async () => {
47
+ test('update changes fields but never id/createdAt', async () => {
48
48
  const store = makeStore();
49
- const t = await store.createTopic({ id: 'topic-u', title: 'A', trailIds: ['t1'] });
50
- const updated = await store.updateTopic('topic-u', { title: 'B', description: 'hi' });
49
+ const t = await store.createTopic({ id: 'topic-u', title: 'A' });
50
+ const updated = await store.updateTopic('topic-u', {
51
+ title: 'B',
52
+ description: 'hi',
53
+ });
51
54
  expect(updated.title).toBe('B');
52
55
  expect(updated.description).toBe('hi');
53
56
  expect(updated.createdAt).toBe(t.createdAt);
54
- expect(updated.trailIds).toEqual(['t1']);
55
57
  });
56
58
 
57
59
  test('repos (PURL strings) persist on create and are updatable', async () => {
@@ -59,7 +61,6 @@ describe('CRUD', () => {
59
61
  const created = await store.createTopic({
60
62
  id: 'topic-repos',
61
63
  title: 'Repo-scoped',
62
- trailIds: [],
63
64
  repos: ['pkg:github/acme/web'],
64
65
  });
65
66
  expect(created.repos).toEqual(['pkg:github/acme/web']);
@@ -78,22 +79,21 @@ describe('CRUD', () => {
78
79
  id: 'topic-pub',
79
80
  title: 'Pub',
80
81
  description: 'has a description',
81
- trailIds: ['local-1'],
82
82
  repos: ['pkg:github/acme/web'],
83
83
  createdAt: '2026-01-01T00:00:00.000Z',
84
84
  updatedAt: '2026-01-02T00:00:00.000Z',
85
85
  };
86
86
  const published = publishedFromDraft(draft, {
87
- trailIds: ['remote-1'],
88
87
  createdBy: { githubId: 1, githubLogin: 'u1' },
89
88
  visibility: 'private',
90
89
  });
91
90
  expect(published.repos).toEqual(['pkg:github/acme/web']);
91
+ expect(published).not.toHaveProperty('trailIds');
92
92
  });
93
93
 
94
94
  test('delete removes the file and the index entry', async () => {
95
95
  const store = makeStore();
96
- await store.createTopic({ id: 'topic-d', title: 'D', trailIds: [] });
96
+ await store.createTopic({ id: 'topic-d', title: 'D' });
97
97
  expect(await store.deleteTopic('topic-d')).toBe(true);
98
98
  expect(existsSync(join(topicsDir, 'topic-d.json'))).toBe(false);
99
99
  expect(await store.getTopic('topic-d')).toBeNull();
@@ -103,7 +103,7 @@ describe('CRUD', () => {
103
103
  test('no eviction cap — many topics all survive', async () => {
104
104
  const store = makeStore();
105
105
  for (let i = 0; i < 120; i++) {
106
- await store.createTopic({ id: `topic-${i}`, title: `T${i}`, trailIds: [] });
106
+ await store.createTopic({ id: `topic-${i}`, title: `T${i}` });
107
107
  }
108
108
  expect((await store.getTopics()).length).toBe(120);
109
109
  });
@@ -112,30 +112,60 @@ describe('CRUD', () => {
112
112
  describe('index rebuild', () => {
113
113
  test('a corrupt _index.json is rebuilt by scanning topic files', async () => {
114
114
  const store = makeStore();
115
- await store.createTopic({ id: 'topic-r', title: 'R', trailIds: ['z'] });
115
+ await store.createTopic({ id: 'topic-r', title: 'R' });
116
116
 
117
117
  // Corrupt the manifest and force a fresh store to rebuild from disk.
118
118
  writeFileSync(join(topicsDir, '_index.json'), 'not json', 'utf8');
119
119
  const fresh = makeStore();
120
120
  const list = await fresh.list();
121
121
  expect(list.map((e) => e.id)).toContain('topic-r');
122
- expect(list.find((e) => e.id === 'topic-r')?.trailCount).toBe(1);
122
+ expect(list.find((e) => e.id === 'topic-r')).not.toHaveProperty('trailCount');
123
+ expect(readFileSync(join(topicsDir, '_index.json'), 'utf8')).not.toContain('trailCount');
124
+ });
125
+
126
+ test('legacy topic files drop trailIds but preserve other fields', async () => {
127
+ const store = makeStore();
128
+ await store.createTopic({
129
+ id: 'topic-legacy',
130
+ title: 'Legacy',
131
+ repos: ['pkg:github/a/b'],
132
+ });
133
+ const topicPath = join(topicsDir, 'topic-legacy.json');
134
+ const legacy = {
135
+ ...JSON.parse(readFileSync(topicPath, 'utf8')),
136
+ trailIds: ['old-trail'],
137
+ description: 'Keep this description',
138
+ };
139
+ writeFileSync(topicPath, JSON.stringify(legacy), 'utf8');
140
+
141
+ const loaded = await store.getTopic('topic-legacy');
142
+
143
+ expect(loaded).toMatchObject({
144
+ id: 'topic-legacy',
145
+ title: 'Legacy',
146
+ description: 'Keep this description',
147
+ repos: ['pkg:github/a/b'],
148
+ });
149
+ expect(loaded).not.toHaveProperty('trailIds');
150
+ expect(JSON.parse(readFileSync(topicPath, 'utf8'))).not.toHaveProperty('trailIds');
123
151
  });
124
152
  });
125
153
 
126
154
  describe('migration', () => {
127
- const seedLegacy = (topics: Partial<DraftTopic>[]) => {
128
- writeFileSync(
129
- legacyBlob,
130
- JSON.stringify({ version: '1.0.0', topics }, null, 2),
131
- 'utf8',
132
- );
155
+ const seedLegacy = (topics: Record<string, unknown>[]) => {
156
+ writeFileSync(legacyBlob, JSON.stringify({ version: '1.0.0', topics }, null, 2), 'utf8');
133
157
  };
134
158
 
135
159
  test('fans the blob out to file-per-topic and backs up the blob', async () => {
136
160
  seedLegacy([
137
- { id: 'topic-1', title: 'One', trailIds: ['a'], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z' },
138
- { id: 'topic-2', title: 'Two', trailIds: [] },
161
+ {
162
+ id: 'topic-1',
163
+ title: 'One',
164
+ trailIds: ['a'],
165
+ createdAt: '2026-01-01T00:00:00.000Z',
166
+ updatedAt: '2026-01-02T00:00:00.000Z',
167
+ },
168
+ { id: 'topic-2', title: 'Two', trailIds: [], repos: ['pkg:github/a/b'] },
139
169
  ]);
140
170
  const store = makeStore();
141
171
  const result = await store.migrateFromLegacyBlob();
@@ -149,6 +179,12 @@ describe('migration', () => {
149
179
  const one = await store.getTopic('topic-1');
150
180
  expect(one?.title).toBe('One');
151
181
  expect(one?.createdAt).toBe('2026-01-01T00:00:00.000Z');
182
+ expect(one).not.toHaveProperty('trailIds');
183
+ expect(await store.getTopic('topic-2')).toMatchObject({
184
+ title: 'Two',
185
+ repos: ['pkg:github/a/b'],
186
+ });
187
+ expect(await store.getTopic('topic-2')).not.toHaveProperty('trailIds');
152
188
  expect(existsSync(join(topicsDir, 'topic-1.json'))).toBe(true);
153
189
  });
154
190
 
@@ -26,15 +26,7 @@
26
26
 
27
27
  import { homedir } from 'node:os';
28
28
  import { dirname, join } from 'node:path';
29
- import {
30
- mkdir,
31
- readFile,
32
- writeFile,
33
- unlink,
34
- readdir,
35
- rename,
36
- access,
37
- } from 'node:fs/promises';
29
+ import { mkdir, readFile, writeFile, unlink, readdir, rename, access } from 'node:fs/promises';
38
30
  import type { DraftTopic, TopicStatus } from './topic-types';
39
31
 
40
32
  /** Root of the global, cross-repo principal narration store. */
@@ -56,7 +48,6 @@ export interface TopicIndexEntry {
56
48
  id: string;
57
49
  title: string;
58
50
  descriptionPreview: string;
59
- trailCount: number;
60
51
  /** `status.state` lifted out for filtering/sorting without a file read. */
61
52
  state?: TopicStatus['state'];
62
53
  createdAt: string;
@@ -75,8 +66,7 @@ interface IndexFileV1 {
75
66
 
76
67
  const emptyIndex = (): IndexFileV1 => ({ version: 1, entries: [] });
77
68
 
78
- const sanitizeSegment = (value: string): string =>
79
- value.replace(/[^A-Za-z0-9._-]/g, '_');
69
+ const sanitizeSegment = (value: string): string => value.replace(/[^A-Za-z0-9._-]/g, '_');
80
70
 
81
71
  const nowIso = (): string => new Date().toISOString();
82
72
 
@@ -91,11 +81,16 @@ const descriptionPreview = (description?: string): string => {
91
81
  return `${trimmed.slice(0, DESCRIPTION_PREVIEW_MAX - 1)}…`;
92
82
  };
93
83
 
84
+ /** Strip the removed topic-to-trail field from data written by older versions. */
85
+ const withoutLegacyTrailIds = (topic: object): Record<string, unknown> => {
86
+ const { trailIds: _trailIds, ...canonical } = topic as Record<string, unknown>;
87
+ return canonical;
88
+ };
89
+
94
90
  const buildEntry = (topic: DraftTopic, sizeBytes: number): TopicIndexEntry => ({
95
91
  id: topic.id,
96
92
  title: topic.title || 'Untitled topic',
97
93
  descriptionPreview: descriptionPreview(topic.description),
98
- trailCount: topic.trailIds.length,
99
94
  state: topic.status?.state,
100
95
  createdAt: topic.createdAt,
101
96
  updatedAt: topic.updatedAt,
@@ -108,7 +103,7 @@ const buildEntry = (topic: DraftTopic, sizeBytes: number): TopicIndexEntry => ({
108
103
  /** Shape the legacy `~/.alexandria/topics.json` blob is parsed as. */
109
104
  interface LegacyTopicsBlob {
110
105
  version?: string;
111
- topics?: DraftTopic[];
106
+ topics?: unknown[];
112
107
  }
113
108
 
114
109
  export interface MigrationResult {
@@ -123,10 +118,8 @@ export interface MigrationResult {
123
118
  }
124
119
 
125
120
  /**
126
- * Fields a caller may set when updating a topic. `id`, `createdAt`, and
127
- * `trailIds` are immutable here — `id`/`createdAt` are set once at creation, and
128
- * `trailIds` is foreign-keyed membership owned by the desktop's trail store, so
129
- * a topic update here can never rewrite it.
121
+ * Fields a caller may set when updating a topic. `id` and `createdAt` are set
122
+ * once at creation and cannot be changed by an update.
130
123
  */
131
124
  export type TopicUpdate = Partial<
132
125
  Pick<DraftTopic, 'title' | 'description' | 'status' | 'createdBy' | 'assets' | 'repos'>
@@ -142,10 +135,7 @@ export type TopicUpdate = Partial<
142
135
  * onto a still-required `id: string` would NOT widen it — an intersection keeps
143
136
  * the stricter member — which would leave `id` wrongly required for callers.
144
137
  */
145
- export type TopicCreate = Omit<
146
- DraftTopic,
147
- 'id' | 'createdAt' | 'updatedAt'
148
- > & {
138
+ export type TopicCreate = Omit<DraftTopic, 'id' | 'createdAt' | 'updatedAt'> & {
149
139
  id?: string;
150
140
  createdAt?: string;
151
141
  updatedAt?: string;
@@ -191,9 +181,7 @@ export class TopicStore {
191
181
  /** Cheap listing straight off the manifest — no per-topic file read. */
192
182
  async list(): Promise<TopicIndexEntry[]> {
193
183
  const idx = await this.getIndex();
194
- return idx.entries
195
- .slice()
196
- .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
184
+ return idx.entries.slice().sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
197
185
  }
198
186
 
199
187
  async createTopic(input: TopicCreate): Promise<DraftTopic> {
@@ -203,14 +191,13 @@ export class TopicStore {
203
191
  if (idx.entries.some((e) => e.id === id)) {
204
192
  throw new Error(`Topic with id '${id}' already exists`);
205
193
  }
206
- const topic: DraftTopic = {
194
+ const topic = {
207
195
  ...input,
208
196
  id,
209
197
  title: input.title || 'Untitled topic',
210
- trailIds: input.trailIds ?? [],
211
198
  createdAt: input.createdAt ?? now,
212
199
  updatedAt: input.updatedAt ?? now,
213
- };
200
+ } as DraftTopic;
214
201
  await this.writeTopic(topic, idx);
215
202
  return topic;
216
203
  }
@@ -222,7 +209,6 @@ export class TopicStore {
222
209
  ...existing,
223
210
  ...updates,
224
211
  id: existing.id,
225
- trailIds: existing.trailIds,
226
212
  createdAt: existing.createdAt,
227
213
  updatedAt: nowIso(),
228
214
  };
@@ -267,26 +253,31 @@ export class TopicStore {
267
253
  blob = JSON.parse(raw) as LegacyTopicsBlob;
268
254
  } catch {
269
255
  throw new Error(
270
- `Legacy topics blob at ${this.legacyBlobPath} is not valid JSON; not migrating.`,
256
+ `Legacy topics blob at ${this.legacyBlobPath} is not valid JSON; not migrating.`
271
257
  );
272
258
  }
273
259
 
274
260
  const idx = await this.getIndex();
275
261
  let migrated = 0;
276
262
  let skipped = 0;
277
- for (const legacy of blob.topics ?? []) {
278
- if (!legacy?.id) {
263
+ const legacyTopics = Array.isArray(blob.topics) ? blob.topics : [];
264
+ for (const legacyValue of legacyTopics) {
265
+ if (!legacyValue || typeof legacyValue !== 'object' || Array.isArray(legacyValue)) {
266
+ skipped++;
267
+ continue;
268
+ }
269
+ const legacy = legacyValue as Record<string, unknown>;
270
+ if (typeof legacy.id !== 'string' || !legacy.id) {
279
271
  skipped++;
280
272
  continue;
281
273
  }
282
274
  const now = nowIso();
283
- const topic: DraftTopic = {
275
+ const topic = {
284
276
  ...legacy,
285
- title: legacy.title || 'Untitled topic',
286
- trailIds: legacy.trailIds ?? [],
287
- createdAt: legacy.createdAt ?? now,
288
- updatedAt: legacy.updatedAt ?? now,
289
- };
277
+ title: typeof legacy.title === 'string' && legacy.title ? legacy.title : 'Untitled topic',
278
+ createdAt: typeof legacy.createdAt === 'string' ? legacy.createdAt : now,
279
+ updatedAt: typeof legacy.updatedAt === 'string' ? legacy.updatedAt : now,
280
+ } as DraftTopic;
290
281
  try {
291
282
  await this.writeTopic(topic, idx);
292
283
  migrated++;
@@ -334,13 +325,14 @@ export class TopicStore {
334
325
 
335
326
  /** Write a topic file and upsert its index entry. Does not generate ids. */
336
327
  private async writeTopic(topic: DraftTopic, idx: IndexFileV1): Promise<void> {
337
- const fileName = `${sanitizeSegment(topic.id)}.json`;
328
+ const canonicalTopic = withoutLegacyTrailIds(topic) as unknown as DraftTopic;
329
+ const fileName = `${sanitizeSegment(canonicalTopic.id)}.json`;
338
330
  const file = join(this.baseDir, fileName);
339
331
  await mkdir(dirname(file), { recursive: true });
340
- const serialized = JSON.stringify(topic, null, 2);
332
+ const serialized = JSON.stringify(canonicalTopic, null, 2);
341
333
  await writeFile(file, serialized, 'utf8');
342
- const entry = buildEntry(topic, Buffer.byteLength(serialized, 'utf8'));
343
- const at = idx.entries.findIndex((e) => e.id === topic.id);
334
+ const entry = buildEntry(canonicalTopic, Buffer.byteLength(serialized, 'utf8'));
335
+ const at = idx.entries.findIndex((e) => e.id === canonicalTopic.id);
344
336
  if (at >= 0) idx.entries[at] = entry;
345
337
  else idx.entries.push(entry);
346
338
  await this.persistIndex();
@@ -349,7 +341,16 @@ export class TopicStore {
349
341
  private async readTopic(fileName: string): Promise<DraftTopic | null> {
350
342
  try {
351
343
  const raw = await readFile(join(this.baseDir, fileName), 'utf8');
352
- return JSON.parse(raw) as DraftTopic;
344
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
345
+ const topic = withoutLegacyTrailIds(parsed) as unknown as DraftTopic;
346
+ if ('trailIds' in parsed) {
347
+ try {
348
+ await writeFile(join(this.baseDir, fileName), JSON.stringify(topic, null, 2), 'utf8');
349
+ } catch (err) {
350
+ console.warn('[TopicStore] Failed to remove legacy trailIds from', fileName, err);
351
+ }
352
+ }
353
+ return topic;
353
354
  } catch (err) {
354
355
  console.error('[TopicStore] Failed to read topic', fileName, err);
355
356
  return null;
@@ -381,7 +382,20 @@ export class TopicStore {
381
382
  const raw = await readFile(this.indexPath, 'utf8');
382
383
  const parsed = JSON.parse(raw) as Partial<IndexFileV1>;
383
384
  if (parsed?.version === 1 && Array.isArray(parsed.entries)) {
384
- return { version: 1, entries: parsed.entries as TopicIndexEntry[] };
385
+ let hadLegacyTrailCount = false;
386
+ const entries = parsed.entries.map((entry) => {
387
+ hadLegacyTrailCount ||= Boolean(
388
+ entry && typeof entry === 'object' && 'trailCount' in entry
389
+ );
390
+ const { trailCount: _trailCount, ...canonical } = entry as TopicIndexEntry & {
391
+ trailCount?: number;
392
+ };
393
+ return canonical;
394
+ });
395
+ if (hadLegacyTrailCount) {
396
+ await writeFile(this.indexPath, JSON.stringify({ version: 1, entries }, null, 2), 'utf8');
397
+ }
398
+ return { version: 1, entries };
385
399
  }
386
400
  } catch (err) {
387
401
  const code = (err as NodeJS.ErrnoException).code;
@@ -408,13 +422,12 @@ export class TopicStore {
408
422
  const topic = await this.readTopic(name);
409
423
  if (!topic?.id) continue;
410
424
  const createdAt = topic.createdAt ?? nowIso();
411
- const stamped: DraftTopic = {
425
+ const stamped = {
412
426
  ...topic,
413
427
  createdAt,
414
428
  updatedAt: topic.updatedAt ?? createdAt,
415
429
  title: topic.title || 'Untitled topic',
416
- trailIds: topic.trailIds ?? [],
417
- };
430
+ } as DraftTopic;
418
431
  const raw = JSON.stringify(stamped, null, 2);
419
432
  idx.entries.push(buildEntry(stamped, Buffer.byteLength(raw, 'utf8')));
420
433
  }
@@ -46,8 +46,18 @@ describe("subsystem-model schema", () => {
46
46
  expect(schema.properties.verifiedAtCommits).toBeDefined();
47
47
  });
48
48
 
49
- test("no longer carries per-declaration revision (superseded by the model-level pin)", () => {
50
- expect(schema.$defs.declarationRef?.properties?.revision).toBeUndefined();
49
+ test("declares a content-addressed source span", () => {
50
+ // The identity primitive: start + end + content hash. Replaces the old
51
+ // start-line-only `declarationRef`.
52
+ expect(schema.$defs.sourceSpan?.properties?.startLine).toBeDefined();
53
+ expect(schema.$defs.sourceSpan?.properties?.endLine).toBeDefined();
54
+ expect(schema.$defs.sourceSpan?.properties?.contentHash).toBeDefined();
55
+ expect(schema.$defs.declarationRef).toBeUndefined();
56
+ });
57
+
58
+ test("trail steps carry a span, not a bare line", () => {
59
+ expect(schema.$defs.trailStep?.properties?.span).toBeDefined();
60
+ expect(schema.$defs.trailStep?.properties?.line).toBeUndefined();
51
61
  });
52
62
 
53
63
  test("a store declares its own value type", () => {