@ccmsg/protocol 1.22.0 โ†’ 1.23.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/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  > ๐Ÿ‡ฏ๐Ÿ‡ต [README-ja.md](./README-ja.md)
4
4
 
5
- The **contract of record** shared by the ccmsg daemon and its web UI. It holds the shape of
6
- every op, event, error code and identifier as a schema, and both sides validate against it.
5
+ The **contract of record** shared by the ccmsg daemon and its web UI. It holds the shape of every op, event, error code and identifier as a schema, and both sides validate against it.
7
6
 
8
- The contract is decided here and the daemon and web UI follow it. Neither side learns the
9
- other's internals by any route that does not pass through it.
7
+ The contract is decided here and the daemon and web UI follow it. Neither side learns the other's internals by any route that does not pass through it.
8
+
9
+ It covers 48 ops over four planes (common, messaging, control, mesh), 13 topics that are each a snapshot plus a stream of changes, one closed union of 21 error codes, and the op attribute table that authorization, capability gating and forwarding all read.
10
10
 
11
11
  ## Install
12
12
 
@@ -24,9 +24,11 @@ OP_ATTRIBUTES["message.send"].roles; // authorization reads the table, not a bra
24
24
  opErrors("session.rename"); // the codes this op may answer with
25
25
  ```
26
26
 
27
+ `@ccmsg/protocol/fixtures` is a second entry point holding a representative JSON of the real wire for every request, reply and topic frame. An implementation's tests read them from here rather than keeping a copy that goes stale.
28
+
27
29
  ## Documentation
28
30
 
29
- - [DESIGN.md](./docs/DESIGN.md) โ€” layers, planes, the op attribute table, and the conventions
31
+ - [DESIGN.md](./docs/DESIGN.md) โ€” the layers, the four planes, the op attribute table, how topics are folded, the item types a transcript is read into, instances and mesh, authenticating a person, and the naming conventions
30
32
 
31
33
  ## License
32
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/protocol",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "Wire contract (schema + types + op attribute table) shared by the ccmsg daemon and web UI",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
package/src/attributes.ts CHANGED
@@ -8,10 +8,10 @@ import type { Capability, Role } from "./identifiers.ts";
8
8
  * different op. */
9
9
  export type Plane = "common" | "messaging" | "control" | "mesh";
10
10
 
11
- /** `instance-local` ops answer for one instance's processes, paths and
11
+ /** `owner_instance` ops answer for one instance's processes, paths and
12
12
  * handles, so they are forwarded to the instance that owns the subject.
13
- * `cluster` ops are answerable by whichever instance is asked. */
14
- export type Locality = "instance-local" | "cluster";
13
+ * `any_instance` ops are answerable by whichever instance is asked. */
14
+ export type Locality = "owner_instance" | "any_instance";
15
15
 
16
16
  export interface OpAttributes {
17
17
  readonly plane: Plane;
@@ -65,47 +65,47 @@ export const OP_ATTRIBUTES = {
65
65
  //
66
66
  // The greetings and `instance.ping` address the instance the caller reached, so
67
67
  // there is nothing to forward and no unreachable instance to report โ€” which
68
- // is why they are `cluster` despite answering about one instance.
68
+ // is why they are `any_instance` despite answering about one instance.
69
69
  "hello.session": {
70
70
  plane: "common",
71
71
  roles: ALL_ROLES,
72
72
  needs_hello: false,
73
- locality: "cluster",
73
+ locality: "any_instance",
74
74
  errors: [],
75
75
  },
76
76
  "hello.user": {
77
77
  plane: "common",
78
78
  roles: ALL_ROLES,
79
79
  needs_hello: false,
80
- locality: "cluster",
80
+ locality: "any_instance",
81
81
  errors: [],
82
82
  },
83
83
  "hello.instance": {
84
84
  plane: "common",
85
85
  roles: ALL_ROLES,
86
86
  needs_hello: false,
87
- locality: "cluster",
87
+ locality: "any_instance",
88
88
  errors: [],
89
89
  },
90
90
  "instance.ping": {
91
91
  plane: "common",
92
92
  roles: ALL_ROLES,
93
93
  needs_hello: true,
94
- locality: "cluster",
94
+ locality: "any_instance",
95
95
  errors: [],
96
96
  },
97
97
  "instance.shutdown": {
98
98
  plane: "common",
99
99
  roles: USER_ONLY,
100
100
  needs_hello: true,
101
- locality: "instance-local",
101
+ locality: "owner_instance",
102
102
  errors: [],
103
103
  },
104
104
  "session.stopping": {
105
105
  plane: "common",
106
106
  roles: SESSION_ONLY,
107
107
  needs_hello: true,
108
- locality: "instance-local",
108
+ locality: "owner_instance",
109
109
  errors: [],
110
110
  },
111
111
  // Open to every role, with which role may have which topic left to the topic
@@ -115,20 +115,20 @@ export const OP_ATTRIBUTES = {
115
115
  plane: "common",
116
116
  roles: ALL_ROLES,
117
117
  needs_hello: true,
118
- locality: "cluster",
118
+ locality: "any_instance",
119
119
  errors: ["topic_unknown"],
120
120
  },
121
121
  "topic.unsubscribe": {
122
122
  plane: "common",
123
123
  roles: ALL_ROLES,
124
124
  needs_hello: true,
125
- locality: "cluster",
125
+ locality: "any_instance",
126
126
  errors: ["topic_unknown"],
127
127
  },
128
128
 
129
129
  // The four ops that authenticate a person are open to every role for the
130
130
  // same reason the greetings are: they run before there is an identity to
131
- // check, and what they answer is what settles one. They are `cluster` because whichever
131
+ // check, and what they answer is what settles one. They are `any_instance` because whichever
132
132
  // instance is reached answers โ€” behind a load balancer that is not a choice
133
133
  // the caller makes โ€” and each asks the issuing instance itself for the parts
134
134
  // only it holds.
@@ -136,7 +136,7 @@ export const OP_ATTRIBUTES = {
136
136
  plane: "common",
137
137
  roles: ALL_ROLES,
138
138
  needs_hello: false,
139
- locality: "cluster",
139
+ locality: "any_instance",
140
140
  carrier: "http",
141
141
  errors: [],
142
142
  },
@@ -144,7 +144,7 @@ export const OP_ATTRIBUTES = {
144
144
  plane: "common",
145
145
  roles: ALL_ROLES,
146
146
  needs_hello: false,
147
- locality: "cluster",
147
+ locality: "any_instance",
148
148
  carrier: "http",
149
149
  errors: ["auth_invalid", "auth_expired", "auth_unknown_issuer"],
150
150
  },
@@ -152,7 +152,7 @@ export const OP_ATTRIBUTES = {
152
152
  plane: "common",
153
153
  roles: ALL_ROLES,
154
154
  needs_hello: false,
155
- locality: "cluster",
155
+ locality: "any_instance",
156
156
  carrier: "http",
157
157
  errors: ["auth_invalid", "auth_expired", "auth_unknown_issuer"],
158
158
  },
@@ -160,7 +160,7 @@ export const OP_ATTRIBUTES = {
160
160
  plane: "common",
161
161
  roles: ALL_ROLES,
162
162
  needs_hello: false,
163
- locality: "cluster",
163
+ locality: "any_instance",
164
164
  carrier: "http",
165
165
  errors: ["auth_invalid", "auth_expired", "auth_unknown_issuer"],
166
166
  },
@@ -170,24 +170,24 @@ export const OP_ATTRIBUTES = {
170
170
  plane: "common",
171
171
  roles: USER_ONLY,
172
172
  needs_hello: true,
173
- locality: "cluster",
173
+ locality: "any_instance",
174
174
  errors: ["auth_invalid", "auth_expired"],
175
175
  },
176
- // Between instances: what an issuer alone can answer. Instance-local by the
176
+ // Between instances: what an issuer alone can answer. `owner_instance` by the
177
177
  // usual rule โ€” the subject belongs to one instance, and it is reached by
178
178
  // `to_instance` being that instance's id.
179
179
  "auth.resolve": {
180
180
  plane: "common",
181
181
  roles: INSTANCE_ONLY,
182
182
  needs_hello: true,
183
- locality: "instance-local",
183
+ locality: "owner_instance",
184
184
  errors: ["auth_invalid", "auth_expired"],
185
185
  },
186
186
  "auth.rotate": {
187
187
  plane: "common",
188
188
  roles: INSTANCE_ONLY,
189
189
  needs_hello: true,
190
- locality: "instance-local",
190
+ locality: "owner_instance",
191
191
  errors: ["auth_invalid", "auth_expired"],
192
192
  },
193
193
 
@@ -196,28 +196,28 @@ export const OP_ATTRIBUTES = {
196
196
  plane: "messaging",
197
197
  roles: AGENT_AND_USER,
198
198
  needs_hello: true,
199
- locality: "cluster",
199
+ locality: "any_instance",
200
200
  errors: ["session_not_found"],
201
201
  },
202
202
  "say.post": {
203
203
  plane: "messaging",
204
204
  roles: SESSION_ONLY,
205
205
  needs_hello: true,
206
- locality: "cluster",
206
+ locality: "any_instance",
207
207
  errors: ["rate_limited"],
208
208
  },
209
209
  "say.unread.clear": {
210
210
  plane: "messaging",
211
211
  roles: USER_ONLY,
212
212
  needs_hello: true,
213
- locality: "cluster",
213
+ locality: "any_instance",
214
214
  errors: [],
215
215
  },
216
216
  "notify.send": {
217
217
  plane: "messaging",
218
218
  roles: AGENT_AND_USER,
219
219
  needs_hello: true,
220
- locality: "cluster",
220
+ locality: "any_instance",
221
221
  errors: ["rate_limited"],
222
222
  },
223
223
 
@@ -226,7 +226,7 @@ export const OP_ATTRIBUTES = {
226
226
  plane: "control",
227
227
  roles: USER_ONLY,
228
228
  needs_hello: true,
229
- locality: "instance-local",
229
+ locality: "owner_instance",
230
230
  errors: ["session_not_found"],
231
231
  },
232
232
  "session.rename": {
@@ -234,42 +234,42 @@ export const OP_ATTRIBUTES = {
234
234
  roles: USER_ONLY,
235
235
  needs_hello: true,
236
236
  capability: "terminal",
237
- locality: "instance-local",
237
+ locality: "owner_instance",
238
238
  errors: ["session_not_found"],
239
239
  },
240
240
  "session.env.read": {
241
241
  plane: "control",
242
242
  roles: USER_ONLY,
243
243
  needs_hello: true,
244
- locality: "instance-local",
244
+ locality: "owner_instance",
245
245
  errors: ["session_not_found"],
246
246
  },
247
247
  "session.search": {
248
248
  plane: "control",
249
249
  roles: USER_ONLY,
250
250
  needs_hello: true,
251
- locality: "instance-local",
251
+ locality: "owner_instance",
252
252
  errors: [],
253
253
  },
254
254
  "session.dump.write": {
255
255
  plane: "control",
256
256
  roles: USER_ONLY,
257
257
  needs_hello: true,
258
- locality: "instance-local",
258
+ locality: "owner_instance",
259
259
  errors: ["not_found"],
260
260
  },
261
261
  "dump.presets.read": {
262
262
  plane: "control",
263
263
  roles: AGENT_AND_USER,
264
264
  needs_hello: true,
265
- locality: "instance-local",
265
+ locality: "owner_instance",
266
266
  errors: [],
267
267
  },
268
268
  "transcript.read": {
269
269
  plane: "control",
270
270
  roles: AGENT_AND_USER,
271
271
  needs_hello: true,
272
- locality: "instance-local",
272
+ locality: "owner_instance",
273
273
  scope: "role",
274
274
  errors: ["not_found"],
275
275
  },
@@ -277,7 +277,7 @@ export const OP_ATTRIBUTES = {
277
277
  plane: "control",
278
278
  roles: AGENT_AND_USER,
279
279
  needs_hello: true,
280
- locality: "instance-local",
280
+ locality: "owner_instance",
281
281
  scope: "role",
282
282
  errors: ["not_found"],
283
283
  },
@@ -286,14 +286,14 @@ export const OP_ATTRIBUTES = {
286
286
  roles: USER_ONLY,
287
287
  needs_hello: true,
288
288
  capability: "fork",
289
- locality: "instance-local",
289
+ locality: "owner_instance",
290
290
  errors: ["not_found"],
291
291
  },
292
292
  "session.forget": {
293
293
  plane: "control",
294
294
  roles: USER_ONLY,
295
295
  needs_hello: true,
296
- locality: "instance-local",
296
+ locality: "owner_instance",
297
297
  errors: [],
298
298
  },
299
299
 
@@ -302,7 +302,7 @@ export const OP_ATTRIBUTES = {
302
302
  plane: "control",
303
303
  roles: AGENT_AND_USER,
304
304
  needs_hello: true,
305
- locality: "instance-local",
305
+ locality: "owner_instance",
306
306
  scope: "role",
307
307
  errors: ["path_forbidden", "not_found"],
308
308
  },
@@ -310,7 +310,7 @@ export const OP_ATTRIBUTES = {
310
310
  plane: "control",
311
311
  roles: AGENT_AND_USER,
312
312
  needs_hello: true,
313
- locality: "instance-local",
313
+ locality: "owner_instance",
314
314
  scope: "role",
315
315
  errors: ["path_forbidden", "not_found"],
316
316
  },
@@ -318,42 +318,42 @@ export const OP_ATTRIBUTES = {
318
318
  plane: "control",
319
319
  roles: USER_ONLY,
320
320
  needs_hello: true,
321
- locality: "instance-local",
321
+ locality: "owner_instance",
322
322
  errors: ["path_not_writable", "file_exists"],
323
323
  },
324
324
  "file.create": {
325
325
  plane: "control",
326
326
  roles: USER_ONLY,
327
327
  needs_hello: true,
328
- locality: "instance-local",
328
+ locality: "owner_instance",
329
329
  errors: ["file_exists", "path_forbidden"],
330
330
  },
331
331
  "file.edit": {
332
332
  plane: "control",
333
333
  roles: USER_ONLY,
334
334
  needs_hello: true,
335
- locality: "instance-local",
335
+ locality: "owner_instance",
336
336
  errors: ["file_conflict", "not_a_text_file"],
337
337
  },
338
338
  "file.delete": {
339
339
  plane: "control",
340
340
  roles: USER_ONLY,
341
341
  needs_hello: true,
342
- locality: "instance-local",
342
+ locality: "owner_instance",
343
343
  errors: ["path_forbidden", "not_found"],
344
344
  },
345
345
  "file.find": {
346
346
  plane: "control",
347
347
  roles: USER_ONLY,
348
348
  needs_hello: true,
349
- locality: "instance-local",
349
+ locality: "owner_instance",
350
350
  errors: ["path_forbidden"],
351
351
  },
352
352
  "file.stat": {
353
353
  plane: "control",
354
354
  roles: USER_ONLY,
355
355
  needs_hello: true,
356
- locality: "instance-local",
356
+ locality: "owner_instance",
357
357
  errors: [],
358
358
  },
359
359
  "dir.tree": {
@@ -361,7 +361,7 @@ export const OP_ATTRIBUTES = {
361
361
  roles: USER_ONLY,
362
362
  needs_hello: true,
363
363
  capability: "launcher",
364
- locality: "instance-local",
364
+ locality: "owner_instance",
365
365
  errors: [],
366
366
  },
367
367
 
@@ -371,7 +371,7 @@ export const OP_ATTRIBUTES = {
371
371
  roles: USER_ONLY,
372
372
  needs_hello: true,
373
373
  capability: "launcher",
374
- locality: "instance-local",
374
+ locality: "owner_instance",
375
375
  errors: [],
376
376
  },
377
377
  "launcher.run": {
@@ -379,7 +379,7 @@ export const OP_ATTRIBUTES = {
379
379
  roles: USER_ONLY,
380
380
  needs_hello: true,
381
381
  capability: "launcher",
382
- locality: "instance-local",
382
+ locality: "owner_instance",
383
383
  errors: [],
384
384
  },
385
385
  "sandbox.grant": {
@@ -387,7 +387,7 @@ export const OP_ATTRIBUTES = {
387
387
  roles: USER_ONLY,
388
388
  needs_hello: true,
389
389
  capability: "sandbox",
390
- locality: "instance-local",
390
+ locality: "owner_instance",
391
391
  errors: ["path_forbidden"],
392
392
  },
393
393
  "sandbox.revoke": {
@@ -395,7 +395,7 @@ export const OP_ATTRIBUTES = {
395
395
  roles: USER_ONLY,
396
396
  needs_hello: true,
397
397
  capability: "sandbox",
398
- locality: "instance-local",
398
+ locality: "owner_instance",
399
399
  errors: [],
400
400
  },
401
401
  "translate.run": {
@@ -403,7 +403,7 @@ export const OP_ATTRIBUTES = {
403
403
  roles: USER_ONLY,
404
404
  needs_hello: true,
405
405
  capability: "translate",
406
- locality: "instance-local",
406
+ locality: "owner_instance",
407
407
  errors: ["translate_helper_failed"],
408
408
  },
409
409
  "llm.usage.read": {
@@ -411,7 +411,7 @@ export const OP_ATTRIBUTES = {
411
411
  roles: USER_ONLY,
412
412
  needs_hello: true,
413
413
  capability: "llm_usage",
414
- locality: "instance-local",
414
+ locality: "owner_instance",
415
415
  errors: [],
416
416
  },
417
417
  "llm.stats.read": {
@@ -419,32 +419,32 @@ export const OP_ATTRIBUTES = {
419
419
  roles: USER_ONLY,
420
420
  needs_hello: true,
421
421
  capability: "llm_stats",
422
- locality: "instance-local",
422
+ locality: "owner_instance",
423
423
  errors: [],
424
424
  },
425
425
 
426
426
  // --- control: the shared key-value store (3) ---
427
- // The only control ops that are not instance-local: a value is held by every
427
+ // The only control ops not answered by an owning instance: a value is held by every
428
428
  // instance rather than by one, so whichever is asked can answer.
429
429
  "kv.read": {
430
430
  plane: "control",
431
431
  roles: USER_ONLY,
432
432
  needs_hello: true,
433
- locality: "cluster",
433
+ locality: "any_instance",
434
434
  errors: ["not_found"],
435
435
  },
436
436
  "kv.write": {
437
437
  plane: "control",
438
438
  roles: USER_ONLY,
439
439
  needs_hello: true,
440
- locality: "cluster",
440
+ locality: "any_instance",
441
441
  errors: [],
442
442
  },
443
443
  "kv.delete": {
444
444
  plane: "control",
445
445
  roles: USER_ONLY,
446
446
  needs_hello: true,
447
- locality: "cluster",
447
+ locality: "any_instance",
448
448
  errors: [],
449
449
  },
450
450
  } as const satisfies Record<string, OpAttributes>;
@@ -466,7 +466,7 @@ export function opErrors(op: OpName): ErrorCode[] {
466
466
  if (attrs.needs_hello) codes.add("hello_required");
467
467
  if (attrs.roles.length < ALL_ROLES.length) codes.add("forbidden");
468
468
  if (attrs.capability !== undefined) codes.add("capability_unavailable");
469
- if (attrs.locality === "instance-local") codes.add("instance_unreachable");
469
+ if (attrs.locality === "owner_instance") codes.add("instance_unreachable");
470
470
  return [...codes];
471
471
  }
472
472
 
@@ -30,7 +30,7 @@ export const REGISTER_TTL_MS = 10 * 60 * 1000;
30
30
  * retention would otherwise carry the removed credential back as news. */
31
31
  export const FAMILY_TOMBSTONE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
32
32
 
33
- /** Who a person is to this cluster. Issued when the registration URL is made
33
+ /** Who a person is to this mesh. Issued when the registration URL is made
34
34
  * (`<unit>-<counter>` by default) and carried by every record they own. */
35
35
  export const Subject = Type.String({ $id: "Subject", minLength: 1, maxLength: 128 });
36
36
  export type Subject = Static<typeof Subject>;
@@ -337,11 +337,11 @@ export const AuthRotateResponse = response("auth.rotate", AuthRotateResult);
337
337
 
338
338
  // --- the replicated records ------------------------------------------------
339
339
 
340
- /** A registered passkey, as every instance in the cluster holds it.
340
+ /** A registered passkey, as every instance in the mesh holds it.
341
341
  *
342
342
  * Complete once it is written: the instance that registered it is not asked
343
343
  * about it again, which is what lets a person authenticate anywhere in the
344
- * cluster while the instance they registered at is down. */
344
+ * mesh while the instance they registered at is down. */
345
345
  export const CredentialRecord = Type.Object(
346
346
  {
347
347
  kind: Type.Literal("credential"),
@@ -503,7 +503,7 @@ export type AuthTombstone = Static<typeof AuthTombstone>;
503
503
  /** One entry of the replicated set, under the key it is matched by. */
504
504
  export const AuthRecord = Type.Object(
505
505
  {
506
- /** What this entry is, cluster-wide. Two instances writing one key hold the
506
+ /** What this entry is, mesh-wide. Two instances writing one key hold the
507
507
  * same thing, and the later `updated_at` is what stands. */
508
508
  key: Type.String({ minLength: 1, maxLength: 256 }),
509
509
  updated_at: Timestamp,
@@ -7,7 +7,8 @@ import { SessionMetaFields } from "../session-meta.ts";
7
7
  *
8
8
  * It is not signed and proves nothing on its own: it names the peer and says
9
9
  * where its one-off key can be fetched. The proof that binds this connection to
10
- * `iss` follows on a separate exchange (mesh-peer-auth ยง5). */
10
+ * `iss` follows on a separate exchange (the daemon's mesh peer authentication
11
+ * decision). */
11
12
  export const MeshHello = Type.Object(
12
13
  {
13
14
  /** Generation of the mesh handshake format, apart from the protocol
@@ -7,8 +7,8 @@ export type InstancePingArgs = Static<typeof InstancePingArgs>;
7
7
 
8
8
  /** How the answering daemon process is running.
9
9
  *
10
- * This is about one process, which is why the op is instance-local: the health
11
- * of the cluster is `hello`'s `instances[]`, not a ping fanned out. */
10
+ * This is about one process, which is why the op is `owner_instance`: the health
11
+ * of the mesh is `hello`'s `instances[]`, not a ping fanned out. */
12
12
  export const InstancePingResult = Type.Object({
13
13
  instance: InstanceId,
14
14
  version: Type.String(),
@@ -93,6 +93,12 @@ export interface TopicAttributes {
93
93
  * `forbidden`, and one naming a capability the instance lacks answers
94
94
  * `capability_unavailable`. */
95
95
  export const TOPIC_ATTRIBUTES = {
96
+ // The one topic where the same subscription means two things: a session is
97
+ // handed what was addressed to it, so what it reads leaves its inbox, while a
98
+ // person is looking at somebody else's mail and reading it moves nothing.
99
+ // Stated on the frame rather than as an attribute here โ€” it is not a fold and
100
+ // not an authorization, and a subscriber does the same thing with the frames
101
+ // whichever it is.
96
102
  inbox: { roles: ["session", "user"], granularity: "element" },
97
103
  notify: { roles: ["session", "user"], granularity: "event" },
98
104
  // A row here changes on its own โ€” one session becomes busy while the rest
@@ -576,7 +576,28 @@ export type DumpIdEntry = Static<typeof DumpIdEntry>;
576
576
  export const DumpIds = Type.Array(DumpIdEntry, { $id: "DumpIds" });
577
577
  export type DumpIds = Static<typeof DumpIds>;
578
578
 
579
- /** The file a dump is written to.
579
+ /** How the selected items are written out.
580
+ *
581
+ * The selection is one thing and its rendering another: which items a dump is
582
+ * of follows from the range and the `types`, and a format decides only what the
583
+ * file then says about them. So all three are dumps of the same items, and the
584
+ * reply describes that selection whichever was asked for.
585
+ *
586
+ * `items` is this contract's own vocabulary, the typed items as
587
+ * `SessionDumpFile`. `records` writes the transcript records those items were
588
+ * read from, unchanged, one JSON document per line โ€” for a tool that already
589
+ * reads the harness's file and wants the classifying alone, which is why
590
+ * nothing of ours is added around them. An item names its record, so several
591
+ * items out of one record are one record here and the line count is not the
592
+ * item count. `text` renders the items for a person to read. */
593
+ export const SessionDumpFormat = Type.Union(
594
+ [Type.Literal("items"), Type.Literal("records"), Type.Literal("text")],
595
+ { $id: "SessionDumpFormat" },
596
+ );
597
+ export type SessionDumpFormat = Static<typeof SessionDumpFormat>;
598
+
599
+ /** The file an `items` dump is written to. The other two formats are not this
600
+ * shape: `records` is the harness's own lines and `text` is prose.
580
601
  *
581
602
  * The reply to a dump names a path rather than carrying the items, so the file
582
603
  * is where they actually travel โ€” which makes its shape as much a part of the
@@ -1,7 +1,7 @@
1
1
  import { type Static, Type } from "@sinclair/typebox";
2
2
  import { request, response } from "../envelope.ts";
3
3
  import { InstanceId, Sid, Timestamp } from "../identifiers.ts";
4
- import { DumpIds, TranscriptItemSelector, TranscriptItemType } from "./dump.ts";
4
+ import { DumpIds, SessionDumpFormat, TranscriptItemSelector, TranscriptItemType } from "./dump.ts";
5
5
 
6
6
  /** Ends the OS process behind a session.
7
7
  *
@@ -203,6 +203,8 @@ export const SessionDumpWriteArgs = Type.Object({
203
203
  /** Leave out the machinery of in-process agents, which
204
204
  * `["-message.sub", "-tool.Agent"]` also says. */
205
205
  no_agent: Type.Optional(Type.Boolean()),
206
+ /** What the file says about the items selected. Absent is `items`. */
207
+ format: Type.Optional(SessionDumpFormat),
206
208
  });
207
209
  export type SessionDumpWriteArgs = Static<typeof SessionDumpWriteArgs>;
208
210
 
@@ -210,12 +212,18 @@ export const SessionDumpWriteResult = Type.Object({
210
212
  /** Absolute path on the writing instance's host. */
211
213
  path: Type.String(),
212
214
  instance: InstanceId,
213
- /** How many items of each type were written, keyed by type name. A single
215
+ /** How many items of each type were selected, keyed by type name. A single
214
216
  * total leaves the caller unable to tell a dump that kept what it asked for
215
- * from one whose selection matched almost nothing. */
217
+ * from one whose selection matched almost nothing.
218
+ *
219
+ * It counts items and not what the file holds, whatever the `format`: the
220
+ * selection is what a caller asked for and what it reads this against, and a
221
+ * count that moved with the rendering would answer a different question each
222
+ * time. */
216
223
  entries: Type.Record(TranscriptItemType, Type.Integer({ minimum: 0 })),
217
224
  /** The ids those items carried, so the next dump โ€” of an agent named here โ€”
218
- * can be asked for without opening the file. */
225
+ * can be asked for without opening the file. Read off the selection like
226
+ * `entries`, whatever the `format`. */
219
227
  ids: DumpIds,
220
228
  bytes: Type.Integer({ minimum: 0 }),
221
229
  });
package/src/envelope.ts CHANGED
@@ -62,7 +62,7 @@ export const RequestEnvelope = Type.Object(
62
62
  *
63
63
  * A forwarded request that names none is dispatched as the `instance` role
64
64
  * it arrived on, which the attribute table already answers: an
65
- * instance-local op called by an instance is `forbidden`. */
65
+ * an `owner_instance` op called by an instance is `forbidden`. */
66
66
  caller: Type.Optional(CallerIdentity),
67
67
  },
68
68
  { $id: "RequestEnvelope" },
package/src/errors.ts CHANGED
@@ -27,7 +27,7 @@ export const ERROR_CODES = [
27
27
  * in their `errors`, since only an op that queues for a reader has a queue to
28
28
  * fill. */
29
29
  "rate_limited",
30
- // --- rule-derived (op attribute table ยง0) ---
30
+ // --- rule-derived (op attribute table) ---
31
31
  /** The connection's role is outside the op's `roles`. Argument problems stay
32
32
  * on `invalid_args` / `bad_request`. */
33
33
  "forbidden",
@@ -35,14 +35,14 @@ export const ERROR_CODES = [
35
35
  "invalid_args",
36
36
  /** The op declares a `capability` this instance does not have. */
37
37
  "capability_unavailable",
38
- /** An `instance-local` op could not be forwarded to the instance that owns
38
+ /** An `owner_instance` op could not be forwarded to the instance that owns
39
39
  * the subject. */
40
40
  "instance_unreachable",
41
41
  // --- subscription ---
42
42
  /** The topic name is not one this protocol generation defines. */
43
43
  "topic_unknown",
44
44
  // --- subject lookup ---
45
- /** The `sid` names no session anywhere in the cluster. */
45
+ /** The `sid` names no session anywhere in the mesh. */
46
46
  "session_not_found",
47
47
  /** The path, transcript, or record named by the arguments does not exist. */
48
48
  "not_found",
@@ -66,7 +66,7 @@ export const ERROR_CODES = [
66
66
  * and `msg` says no more than the instance's own log would want. */
67
67
  "auth_invalid",
68
68
  /** The instance that issued the challenge or registration, and alone can
69
- * spend it, is not one this cluster knows or could reach just now. The client
69
+ * spend it, is not one this mesh knows or could reach just now. The client
70
70
  * asks for a fresh one, which the instance it is talking to can issue. */
71
71
  "auth_unknown_issuer",
72
72
  // --- translate ---
@@ -75,6 +75,7 @@ export const NOTIFY_SEND_REQUEST: Static<typeof NotifySendRequest> = {
75
75
  op: "notify.send",
76
76
  sid,
77
77
  text: "็ขบ่ชใ—ใฆ",
78
+ reply_to: mid,
78
79
  };
79
80
 
80
81
  export const NOTIFY_SEND_RESPONSE: Static<typeof NotifySendResponse> = {
@@ -29,6 +29,7 @@ export const INBOX_FRAME: Static<typeof InboxFrame> = {
29
29
  from_label: "contract-fixtures",
30
30
  text: "fixture ใ‚’ export ใ—ใŸ",
31
31
  sent_at: FIXTURE_NOW,
32
+ to: sid,
32
33
  },
33
34
  {
34
35
  mid: `${instance}/1842`,
@@ -37,15 +38,34 @@ export const INBOX_FRAME: Static<typeof InboxFrame> = {
37
38
  text: "็ขบ่ชใ™ใ‚‹",
38
39
  reply_to: mid,
39
40
  sent_at: FIXTURE_NOW + 1_000,
41
+ to: sid,
40
42
  },
41
43
  ],
42
44
  };
43
45
 
46
+ /** A later frame, where one message has been handed over and another was never
47
+ * taken. */
48
+ export const INBOX_REMOVED_FRAME: Static<typeof InboxFrame> = {
49
+ ev: "topic",
50
+ topic: "inbox",
51
+ instance,
52
+ data: [
53
+ { mid, removed: true, reason: "delivered" },
54
+ { mid: `${instance}/1842`, removed: true, reason: "expired" },
55
+ ],
56
+ };
57
+
44
58
  export const NOTIFY_FRAME: Static<typeof NotifyFrame> = {
45
59
  ev: "topic",
46
60
  topic: "notify",
47
61
  instance,
48
- data: { sid, sid_label: "contract-fixtures", text: "็ขบ่ชใ—ใฆ", sent_at: FIXTURE_NOW },
62
+ data: {
63
+ sid,
64
+ sid_label: "contract-fixtures",
65
+ text: "็ขบ่ชใ—ใฆ",
66
+ reply_to: mid,
67
+ sent_at: FIXTURE_NOW,
68
+ },
49
69
  };
50
70
 
51
71
  const PEER = {
@@ -1,7 +1,7 @@
1
1
  import { type Static, Type } from "@sinclair/typebox";
2
2
 
3
3
  /** A session id: the uuid Claude Code gives its own session. Globally unique,
4
- * so it names a session across the whole cluster without an instance prefix. */
4
+ * so it names a session across the whole mesh without an instance prefix. */
5
5
  export const Sid = Type.String({
6
6
  $id: "Sid",
7
7
  pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
@@ -55,7 +55,7 @@ export type InstanceId = Static<typeof InstanceId>;
55
55
  * identifies where an instance lives changing with it, and what lets the HTTP
56
56
  * routes be spelled without stripping a suffix off first.
57
57
  *
58
- * Compared as a whole string, path included (mesh-peer-auth ยง4.2 โ€” one origin
58
+ * Compared as a whole string, path included (one origin
59
59
  * may host several instances, so an origin-level comparison would confuse
60
60
  * them). The trailing slash is required so that comparison is exact: `/ccmsg`
61
61
  * and `/ccmsg/` would otherwise be two spellings of one instance.
@@ -7,7 +7,7 @@ import { InstanceId, Mid, Sender, Sid, Timestamp } from "../identifiers.ts";
7
7
  * These are not errors: the op succeeded and the message is held in the
8
8
  * recipient's inbox. They tell the sender what to do next โ€” wait, resend to
9
9
  * another session, or give up. The op itself fails only when `to` names no
10
- * session anywhere in the cluster (`session_not_found`). */
10
+ * session anywhere in the mesh (`session_not_found`). */
11
11
  export const UndeliveredReason = Type.Union(
12
12
  [
13
13
  /** Alive, but not yet listening. The daemon delivers when it starts. */
@@ -89,11 +89,57 @@ export const InboxMessage = Type.Object(
89
89
  text: Type.String(),
90
90
  reply_to: Type.Optional(Mid),
91
91
  sent_at: Timestamp,
92
+ /** Who it is addressed to. A session's own subscription is already the
93
+ * recipient, so a row reaching one says nothing by repeating it; a person
94
+ * holds the inbox of every session in one subscription, and a row that
95
+ * does not name its recipient cannot be placed against any of them. So the
96
+ * instance states it on the rows it answers a person with. */
97
+ to: Type.Optional(Sid),
92
98
  },
93
99
  { $id: "InboxMessage" },
94
100
  );
95
101
  export type InboxMessage = Static<typeof InboxMessage>;
96
102
 
103
+ /** Why a message is no longer in the inbox.
104
+ *
105
+ * The three are apart because they are three different things to have happened
106
+ * to a message, and a reader watching for one it sent draws each differently:
107
+ * `delivered` means the recipient has it and its own account of it follows,
108
+ * while the other two mean it never arrived and never will. A removal with no
109
+ * reason would leave a waiting message and an abandoned one looking alike. */
110
+ export const InboxRemovedReason = Type.Union(
111
+ [
112
+ /** Handed to the recipient. */
113
+ Type.Literal("delivered"),
114
+ /** `INBOX_RETENTION_MS` ran out with the recipient never taking it. */
115
+ Type.Literal("expired"),
116
+ /** Dropped, oldest first, to take a newer message into a full inbox โ€”
117
+ * the same event the newer message's sender was told as `inbox_full`. */
118
+ Type.Literal("dropped"),
119
+ ],
120
+ { $id: "InboxRemovedReason" },
121
+ );
122
+ export type InboxRemovedReason = Static<typeof InboxRemovedReason>;
123
+
124
+ /** A message that has left the inbox.
125
+ *
126
+ * A removal has to be a marked element rather than an absence, since a frame
127
+ * carries only what changed and an absence in it says nothing. It names the
128
+ * `mid` every row is matched by, and why โ€” there is no message left to
129
+ * describe, and the reason is the one thing the reader cannot derive. */
130
+ export const InboxRemoved = Type.Object(
131
+ {
132
+ mid: Mid,
133
+ removed: Type.Literal(true),
134
+ reason: InboxRemovedReason,
135
+ },
136
+ { $id: "InboxRemoved" },
137
+ );
138
+ export type InboxRemoved = Static<typeof InboxRemoved>;
139
+
140
+ export const InboxElement = Type.Union([InboxMessage, InboxRemoved], { $id: "InboxElement" });
141
+ export type InboxElement = Static<typeof InboxElement>;
142
+
97
143
  /** How long an undelivered message is kept for its recipient. The same window
98
144
  * a lost session stays listed for: a message outliving the session it was
99
145
  * addressed to would be offered to no one, and a session outliving what was
@@ -106,6 +152,21 @@ export const INBOX_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
106
152
  * accepts is one the recipient can still be handed. */
107
153
  export const INBOX_MAX_PER_SID = 256;
108
154
 
109
- /** The `inbox` topic. Its snapshot is whatever is still undelivered for this
110
- * session; each later frame is one newly arrived message. */
111
- export const InboxFrame = topicFrame("inbox", Type.Array(InboxMessage));
155
+ /** The `inbox` topic: what is waiting, for whoever may see it.
156
+ *
157
+ * Elements, matched by `mid`. The snapshot is what is still undelivered and
158
+ * each later frame is what changed โ€” a message arriving, or one leaving as an
159
+ * `InboxRemoved`.
160
+ *
161
+ * **A session's subscription is the delivery and a person's is a view.** What
162
+ * the session is handed it has been given, and the message leaves its inbox;
163
+ * what a person reads leaves the inbox exactly as it was, because a person is
164
+ * not who any of it was addressed to. The asymmetry is the point rather than an
165
+ * exception: without the view there is no way to see that something sent is
166
+ * still waiting, and a view that consumed what it looked at would deliver
167
+ * messages to no one by being opened.
168
+ *
169
+ * A person therefore sees a message twice over: waiting here, and afterwards in
170
+ * the recipient's own transcript. `mid` is what joins the two, and the removal
171
+ * marked `delivered` is what says the second is coming. */
172
+ export const InboxFrame = topicFrame("inbox", Type.Array(InboxElement));
@@ -1,6 +1,6 @@
1
1
  import { type Static, Type } from "@sinclair/typebox";
2
2
  import { request, response, topicFrame } from "../envelope.ts";
3
- import { Sid, Timestamp } from "../identifiers.ts";
3
+ import { Mid, Sid, Timestamp } from "../identifiers.ts";
4
4
 
5
5
  /** A short line meant to reach a person watching, not the session's own turn.
6
6
  * Delivery is best effort and unacknowledged; unlike `message.send`, nothing is
@@ -9,6 +9,8 @@ export const NotifySendArgs = Type.Object({
9
9
  /** The session the notification is about. Omit to mean the caller. */
10
10
  sid: Type.Optional(Sid),
11
11
  text: Type.String({ minLength: 1 }),
12
+ /** The `mid` this line answers, when it answers one. */
13
+ reply_to: Type.Optional(Mid),
12
14
  });
13
15
  export type NotifySendArgs = Static<typeof NotifySendArgs>;
14
16
 
@@ -24,6 +26,15 @@ export const Notification = Type.Object(
24
26
  /** How the session should be shown, resolved by the issuing instance. */
25
27
  sid_label: Type.String(),
26
28
  text: Type.String(),
29
+ /** What this line answers, when it answers something. A notification is
30
+ * shown while the session's own account of the same answer is still being
31
+ * written, so a reader holding both needs to know they are one thing: the
32
+ * `mid` is the key it matches on, and without it the two stand as two.
33
+ *
34
+ * It says what is answered and never what kind of line this is. A
35
+ * notification is one thing whoever it came from, and a kind would be read
36
+ * as a reason to draw it differently. */
37
+ reply_to: Type.Optional(Mid),
27
38
  sent_at: Timestamp,
28
39
  },
29
40
  { $id: "Notification" },