@forgesworn/fold-kit 0.9.0 → 0.12.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/EXTRACTION.md ADDED
@@ -0,0 +1,549 @@
1
+ # Extraction evidence
2
+
3
+ Prepared from KithMoot branch `test/circle-vectors` (PR #206, not merged at
4
+ extraction time), commit `5babfec`. That branch is origin/main `8260c60`
5
+ plus the T0 wire-compatibility work: `vectors/circle-vectors.json`,
6
+ `vectors/generate-circle.mjs`, `vectors/verify-circle.test.ts`,
7
+ `src/labels.test.ts` and the pure `*_LABELS`/`*_INFO` exports the extraction
8
+ plan required before any code moved. The source checkout used for the
9
+ extraction and for `scripts/diff-source.mjs` was not modified.
10
+
11
+ Prepared under a private extraction plan (task T1.1) that also covers work
12
+ outside this repository's scope; see `docs/extraction-plan-excerpt.md` for
13
+ the parts of it this repository's own docs refer to.
14
+
15
+ ## What moved (§1.1)
16
+
17
+ Copied with only import paths changed (verified byte-identical against the
18
+ pinned commit by `scripts/diff-source.mjs`, whole-file comparison):
19
+
20
+ | Kit file | Source file |
21
+ |---|---|
22
+ | `src/hex.ts` | `src/hex.ts` |
23
+ | `src/verify.ts` | `src/verify.ts` |
24
+ | `src/identity.ts` | `src/identity.ts` |
25
+ | `src/credential.ts` | `src/credential.ts` |
26
+ | `src/room.ts` | `src/room.ts` |
27
+ | `src/network-hints.ts` | `src/network-hints.ts` |
28
+ | `src/display-name.ts` | `src/display-name.ts` |
29
+ | `src/link.ts` | `src/link.ts` |
30
+ | `src/lane.ts` | `src/lane.ts` |
31
+ | `src/invitation.ts` | `src/invitation.ts` (`./relay-pool.js` import → `./transport.js`) |
32
+ | `src/persistent-invitation.ts` | `src/persistent-invitation.ts` (same rewrite) |
33
+ | `src/epoch.ts` | `src/epoch.ts` (same rewrite) |
34
+ | `test/sim-relay.ts` | `test/sim-relay.ts` (same rewrite) |
35
+
36
+ Unit tests moved with their module, unchanged, for every file above except
37
+ `persistent-invitation.test.ts` (see "Adaptations" below). `test/fake-socket.ts`
38
+ did not move: it is only used by `relay-pool.test.ts` and
39
+ `relay-pool-bad-relays.test.ts`, which belong to the relay subpath (T2.2,
40
+ not this task).
41
+
42
+ ## Splits (§1.1)
43
+
44
+ Verified chunk-by-chunk (verbatim doc comment plus declaration) against the
45
+ pinned commit by `scripts/diff-source.mjs`:
46
+
47
+ - **`kinds.ts`** → this kit's `src/kinds.ts` is a subset: `CREDENTIAL`,
48
+ `CHAT`, `INVITATION_REQUEST`, `INVITATION_GRANT`, `INVITATION_RETIREMENT`,
49
+ `GROUP_INVITATION`, `ROOM_REKEY`, `EPOCH_REQUEST`, `EPOCH_GRANT`, each
50
+ copied with its doc comment unchanged. `CHAT` (1460) is included because
51
+ a downstream app's own board events are designed to share KithMoot's
52
+ chat kind (see `docs/extraction-plan-excerpt.md`).
53
+ - **`types.ts`** → this kit's `src/types.ts` is a subset: `DeviceCredential`,
54
+ `AccessTier`, `AgentRule`, `RoomPolicy`, `KindredProof`. `Reachability`
55
+ (imported by KithMoot's `types.ts` for `RosterEntry`, `AssistOffer` and
56
+ others) is not needed by this subset and does not move - the plan's
57
+ "Corrections after the T0 vector review" flags this explicitly.
58
+ - **`access.ts`** → this kit's `src/access.ts` keeps `issueKindredProof`,
59
+ `evaluateAccess`, the `canonicalMessage` helper and `ACCESS_LABELS`, all
60
+ unchanged. `evaluateAgentAccess` stayed in KithMoot: it needs
61
+ `ownership.ts` and `RosterEntry`, neither of which moved.
62
+ - **`chat.ts`** → `deriveChannel`, `CHANNEL_ID_INFO`, `CHANNEL_KEY_INFO` and
63
+ `MAX_CHANNEL_NAME_LENGTH` moved into this kit's new `src/channel.ts`
64
+ (renamed from the source's `CHAT_LABELS` to `CHANNEL_LABELS`, since the
65
+ export now lives in its own file - the two frozen string values are
66
+ unchanged). `ChatLog` and the chat event codecs stayed in KithMoot.
67
+
68
+ ## What did not move (per the plan's corrections)
69
+
70
+ - The relay pool (`relay-pool.ts`, `relay-auth.ts`, `anonymous.ts`) stays in
71
+ KithMoot until T2.2, after its churn settles (Decision 8). This kit
72
+ declares its own `RelayTransport` interface in `src/transport.ts`
73
+ (`publish`/`subscribe`/`close`) rather than importing KithMoot's - the
74
+ three codecs that need a transport (`epoch.ts`, `invitation.ts`,
75
+ `persistent-invitation.ts`) only ever call those three methods.
76
+ - `Reachability` stays in KithMoot (see "Splits" above).
77
+
78
+ ## Vectors
79
+
80
+ - `vectors/kithmoot-vectors.json`: the circle-layer subset of KithMoot's own
81
+ vector file - the groups `roomDerivation`, `channelDerivation`, `joinUrl`,
82
+ `deviceCredential`, `kindredProof`, `accessEvaluation`, `roomEpoch` and
83
+ `epochRequestAdmission`, copied group-for-group with no edits (same
84
+ `protocolVersion`/`nostrToolsVersion` header). The other groups in
85
+ KithMoot's file (roster event, signal wrap, room descriptor, TURN
86
+ credential, agent ownership, chat attachment, approval control,
87
+ verification words, the message layer) belong to modules that did not
88
+ move and are not carried here.
89
+ - `vectors/circle-vectors.json`: copied whole and unedited from the pinned
90
+ commit - every one of its 8 groups (`linkEnvelope`, `invitationEnvelope`,
91
+ `persistentInvitation`, `epochGrant`, `epochRequest`, `personCredential`,
92
+ `channelsSignature`, `accessEvaluation`) belongs to the circle layer.
93
+ - `vectors/lib/determinism.mjs`, `vectors/lib/fixtures.mjs`: copied
94
+ unchanged. Both are pure, labelled-input helpers with no KithMoot-specific
95
+ behaviour.
96
+ - `vectors/verify.test.ts`: adapted from KithMoot's `vectors/verify.test.ts`
97
+ - only the describe blocks for the 8 circle groups above, with
98
+ `deriveChannel`'s import moved from `../src/chat.js` to `../src/channel.js`.
99
+ Every retained assertion is unchanged.
100
+ - `vectors/verify-circle.test.ts`: copied unchanged except one comment line
101
+ naming the private planning document this repository does not name (see
102
+ "Public repository naming" below) - every import it needs (`kinds.js`,
103
+ `room.js`, `link.js`, `network-hints.js`, `display-name.js`,
104
+ `invitation.js`, `persistent-invitation.js`, `credential.js`, `epoch.js`,
105
+ `access.js`) already matches this kit's module layout one-to-one.
106
+
107
+ ## Labels
108
+
109
+ `src/labels.test.ts` is adapted from KithMoot's own label test: same
110
+ scanning regex and per-module ownership check, cut down to the 15 labels
111
+ this kit's moved modules own (KithMoot's full frozen list has more, owned by
112
+ modules that stayed - roster, signalling, pairing, call bell, and so on).
113
+ Every label byte value is unchanged from KithMoot's frozen list.
114
+
115
+ ## Adaptations (not wire changes)
116
+
117
+ - `src/persistent-invitation.test.ts` drops one upstream test ("lets an
118
+ agent join and send chat from only stored admission") that drives
119
+ `RoomAgent.join` - an app-level integration point (`agent.ts`, `chat.ts`)
120
+ that is not part of this kit's boundary. The `SimRelay`/`SimTransport`
121
+ replay path that test also exercises is already covered by the other
122
+ tests in the same file.
123
+ - `src/display-name.test.ts` drops one upstream describe block ("the render
124
+ path" / "never puts a display name through innerHTML"), which scans
125
+ KithMoot's `app/src/main.ts` - the PWA source, which does not exist in
126
+ this kit. The rest of the file, including the NUL-byte control-character
127
+ fixture (see its own header comment), is unchanged.
128
+ - `src/kinds.test.ts` and `src/channel.test.ts` are new files: the former
129
+ keeps the "circle-layer kind numbers" uniqueness check from KithMoot's
130
+ `src/api-surface.test.ts` (whose snapshot machinery tracks KithMoot's
131
+ whole library surface and stayed in KithMoot); the latter keeps the two
132
+ `deriveChannel`-only cases from KithMoot's `src/chat.test.ts` "channels"
133
+ describe block (the others need `ChatLog`/`encodeChatEvent`, which stayed
134
+ in KithMoot).
135
+
136
+ ## Package
137
+
138
+ ESM only, `exports` for `.` (circle primitives), `./lane` (lane helpers),
139
+ `./mls-admission` (opt-in v4 codecs), `./persistent-invitation-evidence`
140
+ (bounded stored invitation observations) and `./package.json`.
141
+ Peer dependencies (`nostr-tools >=2.24.2 <3`,
142
+ `@noble/hashes ^1.8.0`, `@noble/curves ^2.0.1`) matching KithMoot's pinned
143
+ versions so a consumer keeps a single copy of each, `engines.node >=22.13`,
144
+ `sideEffects: false`. `@noble/hashes` is pinned to its 1.x major only:
145
+ this kit's `@noble/hashes` imports have no `.js` suffix and its `hkdf`
146
+ calls pass a string `info` argument, and 2.x's `exports` map only resolves
147
+ `.js`-suffixed subpaths while its `hkdf` rejects a string `info` - so 2.x
148
+ cannot satisfy these imports as written.
149
+
150
+ ## Phase 3 additions (T3.1, T3.3): new code, not moved from KithMoot
151
+
152
+ The plan's Phase 3 (`docs/extraction-plan-excerpt.md`, gaps G1 and G3) calls
153
+ for new kit code beyond what moved from KithMoot. Two tasks are done:
154
+
155
+ - **T3.1, `src/scoped.ts`** (G1): `deriveScoped(epoch, label)`, an
156
+ HKDF-SHA256 derivation from an epoch's KEY under an app-defined labelled
157
+ namespace, so a downstream app (the private planning document's board
158
+ tool, for one) can derive its own payload keys and stream ids under the
159
+ same epoch as the roster and the chat - rotating together on a rekey -
160
+ without ever colliding with, or being mistaken for, one of this kit's own
161
+ derivations. Refuses this kit's own protocol namespace (checked via a
162
+ prefix built by string concatenation rather than a literal, so the check
163
+ itself does not add a new entry to `src/labels.test.ts`'s frozen scan -
164
+ see the comment at its call site) and any label outside
165
+ `^[a-z0-9-]{1,64}/v[0-9]{1,9}/[\x21-\x7e]{1,200}$` - the namespace and
166
+ version segments are capped (64 bytes, 9 digits) because a label is always
167
+ a short, fixed, hand-written string, never something worth accepting
168
+ unboundedly just because the regex could technically match it.
169
+ - **T3.3, `src/sub-cert.ts`** (G3): `createSubKeyCertificate` /
170
+ `verifySubKeyCertificate`, a sub-key certificate - a small, never-published
171
+ kind-20460 event with `scope: "sub"`, signed by a credentialled device,
172
+ binding an app-derived key (typically a `deriveScoped` output) to that
173
+ device for one scoped id. `verifyDeviceCredential` already refuses any
174
+ `scope` it does not recognise, so this new form can never be accepted as a
175
+ room or person credential by the existing, unmodified verifier - no change
176
+ to `credential.ts`'s verification logic was needed for this task.
177
+ Verification is strict and canonical: exactly four tags, each exactly
178
+ `[name, value]`, in a fixed order (`d`, `device`, `expiration`, `scope`);
179
+ `content` must be the empty string; `expiration` must be a canonical
180
+ decimal integer (no sign, no `0x` prefix, no fraction, no leading zeros,
181
+ no surrounding whitespace) compared as the EXACT TAG STRING - not
182
+ numerically - against the accompanying device credential's own
183
+ `expiration` tag. `verifySubKeyCertificate` takes that credential event
184
+ directly (`opts.credential`, already verified by the caller via
185
+ `verifyDeviceCredential`) and reads its `device` and `expiration` tags
186
+ itself, rather than trusting a caller to have copied them out correctly -
187
+ an earlier revision took `device`/`credentialExpiresAt` as separate
188
+ caller-supplied options, which an independent review found let the two
189
+ drift apart from whatever `verifyDeviceCredential` had actually accepted.
190
+ `createSubKeyCertificate` validates its own inputs before asking a signer
191
+ to sign anything (`signerId`/`subKeyPubkey` must be 32-byte hex,
192
+ `expiresAt` a safe integer strictly in the future).
193
+
194
+ Both are pure additions: no existing exported function's signature or body
195
+ changed, and both are covered by `src/scoped.test.ts` / `src/sub-cert.test.ts`
196
+ (unit tests, including a mutation-tested refusal for every security-relevant
197
+ check - see "Mutation testing" below) and `vectors/fold-vectors.json`
198
+ (known-answer vectors, generated by `scripts/generate-fold.mjs` and
199
+ independently recomputed against `src/` by `vectors/verify-fold.test.ts`).
200
+ Every negative certificate vector is validly signed (re-signed under its
201
+ mutated tags/content) rather than reusing the positive certificate's stale
202
+ signature over different bytes, so each one fails for exactly the reason
203
+ under test. `vectors/verify-fold.test.ts` also recomputes every
204
+ `deriveScoped` vector a second way, calling `@noble/hashes`' `hkdf` directly
205
+ rather than `deriveScoped`, so a shared bug between the generator and
206
+ `deriveScoped` itself cannot round-trip undetected. Unlike
207
+ `kithmoot-vectors.json` and `circle-vectors.json`, this vector file has no
208
+ upstream KithMoot counterpart and is not covered by `scripts/diff-source.mjs`.
209
+
210
+ The forgesworn/kithmoot#205 credential fix is a separate change, on its own
211
+ branch/PR (`fix/restamp-205`) based on this one: the `feat/scoped-keys` tip
212
+ this branch is based on makes no change to `credential.ts` at all; only the
213
+ commit below does.
214
+
215
+ ### Mutation testing
216
+
217
+ Every security-relevant check in `src/scoped.ts` and `src/sub-cert.ts` was
218
+ mutated (the check deleted, or its condition forced to never fire) one at a
219
+ time, and the full targeted test suite (`src/scoped.test.ts`,
220
+ `src/sub-cert.test.ts`, `vectors/verify-fold.test.ts`) was re-run after each
221
+ mutation. All 29 mutations were killed (caused at least one test to fail):
222
+ 3 in `scoped.ts` (the protocol-namespace refusal, the label-pattern check,
223
+ the epoch-key-length check) and 26 in `sub-cert.ts` (every tag-name check
224
+ individually - `d`, `device`, `expiration` and `scope`, each checked at its
225
+ own fixed position rather than only via a full reorder - the tag-count
226
+ check, the per-tag-shape check, the scope-value check, the signer-id check,
227
+ the sub-key/message-signer check, the credential-missing-device-tag check,
228
+ the wrong-device check, the canonical-expiration-format check, the
229
+ credential-missing-expiration-tag check, the expiration-equality check, the
230
+ expired check, the at/skew bound, the certificate signature check, both
231
+ mint-time hex-shape validations, the mint-time future-integer check, and
232
+ all four of `createSubKeyCertificate`'s post-signing equality checks -
233
+ signer pubkey, kind/content, tags, and `verifyEventUncached`). Each mutation
234
+ was reverted immediately after its run; none is present in the committed
235
+ code. The `RestampedCredentialExpiryError`/`overBySeconds` check the #205
236
+ fix below adds was likewise mutated and killed (see that section).
237
+
238
+ ## The #205 fix: the one deliberate difference from the pinned source
239
+
240
+ `src/credential.ts` is not quite byte-identical to the pinned commit: it
241
+ carries one small, deliberate fix for
242
+ [forgesworn/kithmoot#205](https://github.com/forgesworn/kithmoot/issues/205).
243
+
244
+ The bug: `createDeviceCredential`'s 30-day cap on a person-scope credential
245
+ is checked against the *requested* `now` (`opts.expiresAt - now`), but
246
+ `verifyDeviceCredential`'s matching check is against the *signed*
247
+ `created_at` (`expiresAt - cred.created_at`). A remote signer (a bunker, a
248
+ phone) that restamps `created_at` to its own, earlier clock - which
249
+ `createDeviceCredential` deliberately tolerates, since some signers do this
250
+ and nothing here should assume otherwise - can then produce a credential
251
+ that mints successfully but is refused as `"longer than 30 days"` by every
252
+ verifier, including `verifyDeviceCredential` itself.
253
+
254
+ The fix is two small additions, both stripped by name in
255
+ `scripts/diff-source.mjs` (see below): a typed error,
256
+ `RestampedCredentialExpiryError` (exported from the package root, carrying
257
+ `overBySeconds`, so a caller can tell this failure apart from the function's
258
+ other, unrelated failure modes and retry specifically for it), and a check
259
+ right after the existing post-signing checks in `createDeviceCredential`
260
+ that re-measures the cap using the verifier's own calculation (against
261
+ `signed.created_at`) and throws that error immediately if it is over, rather
262
+ than returning a credential doomed to be refused everywhere. A caller that
263
+ hits this (as the downstream sync spec's own retry logic does) asks again
264
+ with a shorter `expiresAt` margin. `verifyDeviceCredential` itself is
265
+ untouched - its measurement was already correct; only the mint side was
266
+ checking the wrong clock.
267
+
268
+ `scripts/diff-source.mjs` proves these two additions are the *only*
269
+ difference: it strips exactly these two declared blocks (each matched
270
+ verbatim) from `src/credential.ts` before comparing the rest of the file,
271
+ byte for byte, against the pinned commit. If either block's text ever
272
+ drifted from what the script declares, the strip would stop matching and
273
+ the check would fail loudly rather than silently widening what counts as
274
+ "the declared difference".
275
+
276
+ This fix changes the behaviour one existing vector was written to pin: the
277
+ `personCredential`/`refused-over-30-days` vector in `circle-vectors.json`
278
+ (KithMoot's own `M15`) exercises exactly the restamp-at-the-cap scenario the
279
+ bug describes, and was frozen expecting a successful mint followed by a
280
+ verifier refusal. That vector's `output` keeps both halves of M15's
281
+ original coverage - `event` and `result` are still the exact over-cap event
282
+ a pre-#205 mint would have produced (built deterministically in the test,
283
+ since the real `createDeviceCredential` now refuses to produce it) and
284
+ `verifyDeviceCredential`'s own refusal of it, byte-identical to before - and
285
+ adds `mintThrew`, the new mint-time refusal message. The vector's `note` and
286
+ the corresponding test in `vectors/verify-circle.test.ts` are updated to
287
+ check all three - the issue itself anticipated this ("The vectors pin
288
+ current [i.e. buggy] behaviour; this fix is separate"). Every other vector
289
+ in both `kithmoot-vectors.json` and `circle-vectors.json`, and their
290
+ generators, are untouched. `src/credential-205.test.ts` adds two more cases
291
+ through the real function: the same at-the-cap restamp now throwing
292
+ (asserted as `RestampedCredentialExpiryError`, with a mutation-tested check
293
+ on `overBySeconds`), and a one-hour-margin request succeeding under the same
294
+ restamp.
295
+
296
+ **This is a breaking behaviour change for `createDeviceCredential`, called
297
+ out in `CHANGELOG.md`.** KithMoot's own `src/credential.ts` has the same
298
+ bug, unfixed; `CHANGELOG.md` says what has to change there (`M15` in its
299
+ `vectors/verify-circle.test.ts` and `vectors/generate-circle.mjs`) in a
300
+ paired PR.
301
+
302
+ ## Conference rooms: declared additions to three moved modules
303
+
304
+ 0.3.0 adds conference rooms - a persistent group with a fixed end, carried
305
+ as a NIP-40 expiration - to `invitation.ts` (retirement `endsAt`),
306
+ `persistent-invitation.ts` (invitation `endsAt`, body `ends`, the decoder's
307
+ tag/body agreement check, earliest end wins in
308
+ `requestPersistentRoomAdmission`) and `epoch.ts` (`expiresAt` on the rekey,
309
+ request and grant encoders and both desk functions). The rule itself lives
310
+ in a new module, `src/expiration.ts`, which moved from nowhere.
311
+
312
+ Each addition is optional and additive: with it absent, every event these
313
+ modules sign is byte-identical to before, and every existing vector still
314
+ verifies. `scripts/diff-source.mjs` lists each added or altered line in
315
+ `CONFERENCE_CHANGES` as `[kit text, pinned source text, count]`, checks
316
+ each occurs exactly `count` times, puts the source text back, and then
317
+ compares the whole file against the pinned commit as before - so these are
318
+ the only differences, and a drifted line fails the check rather than
319
+ widening it. KithMoot's own copies of these modules are re-export shims
320
+ since its cutover, so it picks this up on its next version bump.
321
+
322
+ ## Room relays: declared additions to `persistent-invitation.ts`
323
+
324
+ 0.4.0 adds the room's own relays to the group invitation body, after
325
+ `ends`: the relays a room is created on, which every member's pool then
326
+ includes. The rule (one to eight distinct, safe, canonical URLs) lives in a
327
+ new module, `src/invitation-relays.ts`, which moved from nowhere. With no
328
+ `relays` the body is byte-identical to 0.3.0's. `scripts/diff-source.mjs`
329
+ declares each added line in `ROOM_RELAY_CHANGES` as 0.4.0 text against
330
+ 0.3.0 text, applied before `CONFERENCE_CHANGES`, so the whole-file
331
+ comparison against the pinned commit still holds.
332
+
333
+ ## Member epoch catch-up: declared additions to `epoch.ts`
334
+
335
+ Unreleased. Any current member can now hand an admitted, non-removed device
336
+ the epochs it missed, with the authority's own rekeys as evidence (see
337
+ `docs/member-epoch-catch-up.md`). The new code lives in two new modules
338
+ that moved from nowhere: `src/epoch-commit.ts` (the commitment, a leaf with
339
+ no imports from `epoch.ts`, so there is no import cycle) and
340
+ `src/member-epoch.ts` (the member request and grant, the desk and the
341
+ requester). Its kinds, 20471 and 20472, are exported from there as
342
+ `MEMBER_EPOCH_KINDS` rather than added to `KINDS`, whose body
343
+ `scripts/diff-source.mjs` proves is exactly the pinned source's.
344
+
345
+ `epoch.ts` gains two optional, additive options: `commit` on
346
+ `encodeRekeyEvent` (writes the commitment into the body) and `members` on
347
+ `requestRoomEpoch` (a second source of answers). With both absent, every
348
+ event is byte-identical to 0.4.0's and every existing vector still verifies.
349
+ `scripts/diff-source.mjs` declares each added line in
350
+ `MEMBER_EPOCH_CHANGES`, as new text against 0.4.0 text, applied before
351
+ `ROOM_RELAY_CHANGES` and `CONFERENCE_CHANGES`, so the whole-file comparison
352
+ against the pinned commit still holds.
353
+
354
+ ## Seal keys: declared additions to `credential.ts` and `epoch.ts`
355
+
356
+ 0.7.0. A device credential may name the device's seal key, and rekeys and
357
+ epoch grants are sealed to it instead of the device key (see
358
+ `docs/seal-key.md`). The new code lives in `src/seal.ts`, which moved from
359
+ nowhere. `credential.ts` gains the optional `seal` on
360
+ `createDeviceCredential`; `epoch.ts` gains seal-keyed recipients, `sealSks`
361
+ on the decoders and `requestRoomEpoch`, `credential` on `encodeEpochGrant`,
362
+ `credentialFor` on `hostRoomEpoch`, and `sealCredential`. With no seal key
363
+ anywhere, every event is byte-identical to 0.6.0's. `scripts/diff-source.mjs`
364
+ declares each hunk in `SEAL_CHANGES`, as 0.7.0 text against 0.6.0 text,
365
+ applied before every other change set but `SCHEDULE_CHANGES` (and, for
366
+ `credential.ts`, before the #205 blocks are stripped).
367
+
368
+ ## Scheduled rekeys: declared additions to `epoch.ts`
369
+
370
+ 0.8.0. A rekey may be marked `scheduled`, the history window is a shared
371
+ rule (`HISTORY_WINDOW_SECONDS`, `MAX_HISTORY_EPOCHS`, `epochsInWindow`), and
372
+ the authority's grant carries the window's left epochs (`passed`, and `past`
373
+ on `hostRoomEpoch`). See `docs/scheduled-rekey.md`. A rekey without the
374
+ marker, and a grant without `passed`, is byte-identical to 0.7.0's.
375
+ `scripts/diff-source.mjs` declares each hunk in `SCHEDULE_CHANGES`, as 0.8.0
376
+ text against 0.7.0 text, applied before every other change set but `DESTRUCT_CHANGES`. The matching
377
+ reader changes in `member-epoch.ts` (`RekeyEvidence.scheduled`, and `leftAt`
378
+ on a member grant's passed epochs) need no declaration: that module is new
379
+ code and not compared against the pinned source.
380
+
381
+ ## Self-destructing rooms: declared additions to three moved modules
382
+
383
+ 0.9.0. An optional `destruct: true` in the group invitation body
384
+ (`persistent-invitation.ts`), in an ended room's retirement
385
+ (`invitation.ts`) and in a closing rekey's body (`epoch.ts`), each surfaced
386
+ on its reader, and kept by `requestPersistentRoomAdmission` if any signed
387
+ copy carries it. See `docs/room-destruct.md`. With the flag absent every
388
+ event is byte-identical to 0.8.0's. `scripts/diff-source.mjs` declares each
389
+ hunk in `DESTRUCT_CHANGES`, as 0.9.0 text against 0.8.0 text, applied before
390
+ every other change set. The matching reader change in `member-epoch.ts`
391
+ (`RekeyEvidence.destruct`) needs no declaration.
392
+
393
+ ## Public repository naming
394
+
395
+ This repository is public. It does not name the private downstream app that
396
+ motivated some of the moves and split points above (the board-events kind
397
+ share, for instance) - see `docs/extraction-plan-excerpt.md`, which carries
398
+ the neutral wording used throughout this repository's docs, comments and
399
+ tests in place of that name.
400
+
401
+ ## For KithMoot's cutover (T2.1, not this task)
402
+
403
+ Notes for whoever writes KithMoot's re-export shims, so the codec cutover
404
+ does not silently change KithMoot's own public surface:
405
+
406
+ - Shims must use named `export { … }` / `export type { … }`, not
407
+ `export * from '@forgesworn/fold-kit'`. A wildcard re-export would pull in
408
+ all of this kit's ~96 root exports, including several (such as `KINDS`
409
+ itself, or `RelayTransport`) that KithMoot's own modules already export
410
+ under the same name from elsewhere - and it would change KithMoot's
411
+ `src/api-surface.test.ts` snapshot for every shimmed file, which is
412
+ exactly what that snapshot exists to catch.
413
+ - The `chat.ts` shim must not re-export `CHANNEL_LABELS`: that name does
414
+ not exist in KithMoot today (its own label is `CHAT_LABELS`, kept in
415
+ KithMoot since `ChatLog` and the chat codecs did not move), and
416
+ `src/labels.test.ts` would otherwise see two different exported lists
417
+ claiming the same two label strings.
418
+ - The `kinds.ts` shim: KithMoot's `KINDS` object should spread this kit's
419
+ `KINDS` first, then KithMoot's own remaining kind entries, and drop
420
+ KithMoot's own now-duplicate `CHAT` field (this kit's `KINDS.CHAT` is
421
+ byte-identical to KithMoot's, so the spread order does not change the
422
+ value - only which module's object literal defines it).
423
+ - The `types.ts` shim must `import type` whichever of this kit's types
424
+ KithMoot's own `types.ts` still uses (`DeviceCredential`, `AccessTier`,
425
+ `RoomPolicy`, `KindredProof`) rather than redeclaring them, so there is
426
+ exactly one definition of each.
427
+
428
+ ## Validation
429
+
430
+ - `npm run check` (typecheck + test + diff-source) on Node 22.13 and Node
431
+ 24: green on both.
432
+ - `scripts/diff-source.mjs` against commit `5babfec`: zero differences.
433
+ - Both vector files verify against this kit's own functions.
434
+ - `scripts/bundle-check.mjs`: esbuild browser bundle of all entry points,
435
+ no `node:` import, within the recorded size budget.
436
+ - `scripts/tarball-smoke.mjs`: `npm pack`, install into a scratch directory,
437
+ import and exercise every export from all entry points.
438
+
439
+ KithMoot has cut over to this kit (T2.1) and pins an exact npm version;
440
+ its moved files are re-export shims. Any change here that alters behaviour
441
+ KithMoot's vectors record (for example the #205 fix) lands in KithMoot in
442
+ the same PR as the version bump.
443
+
444
+ ## Live persistent admission
445
+
446
+ `src/live-persistent-admission.ts` is a new opt-in profile, not a change to a
447
+ moved invitation module. It uses kinds 20466/20467 with a distinct request KDF
448
+ and body profile, preserves kind 1463 bytes, and grants no delegation. Its
449
+ descriptor, challenge and answer codecs own no transport or storage. The host
450
+ lifecycle journal, client epoch gate and physical transport remain separate
451
+ integration requirements; see `docs/live-persistent-admission.md`.
452
+
453
+ ## Grant publication callbacks
454
+
455
+ The host's publication callbacks now follow settlement of the injected
456
+ transport's `publish(grant)` promise. `onGrantPublished(request)` correlates
457
+ success to the request; the legacy `onAdmitted(device)` observes the same
458
+ acknowledgement. `onGrantFailed(request, error)` exposes a rejected or throwing
459
+ send. A closed or retired host suppresses outstanding callbacks, and observer
460
+ exceptions cannot change the publication outcome. These are local API and
461
+ timing changes; request/grant encoders, kinds, envelopes and derivation labels
462
+ are unchanged. `GRANT_PUBLICATION_CHANGES` in `scripts/diff-source.mjs` reverses
463
+ the two exact interface/host hunks before comparing with the extracted source.
464
+
465
+ ## Invitation account proof and bounded request lifecycle
466
+
467
+ The live invitation request now accepts an optional `accountProof`. The new
468
+ proof profile signs kind 20466 with exactly the tags
469
+ `t=kithmoot/v2/invitation-account-proof`, `d=<invitation id>` and
470
+ `p=<pinned inviter>`, content `{"v":1,"device":"<request device>"}` and the
471
+ outer request's timestamp. Its account pubkey must match `participant`.
472
+ The version-1 proof body is a new profile under its own domain; the existing
473
+ version-1 encrypted request body retains its bytes when the field is absent.
474
+ Existing readers ignore the optional field. No existing derivation label,
475
+ kind or grant format changes. The proof is nested inside the bearer-encrypted
476
+ request and must never be independently published.
477
+
478
+ `encodeInvitationAccountProof` validates the matching signer's actual returned
479
+ signature and context. `decodeInvitationRequest` exposes `verifiedParticipant`
480
+ only after an uncached verification of the matching proof. The existing
481
+ `participant` remains an unverified claim; invalid proofs do not prevent a
482
+ host from considering an anonymous/manual request, but cannot establish an
483
+ account's authority. Consumers must use `verifiedParticipant` for automatic
484
+ account-based admission. New known-answer vectors record the new profile,
485
+ legacy requests and forged or substituted nested proofs.
486
+
487
+ The request helper optionally creates the proof using its caller's matching
488
+ identity. Account signing and relay waiting share a deadline no longer than
489
+ the 90-second request freshness window. Cancellation or retirement suppresses
490
+ late signing/publication; only a cloned request key is erased. Host decisions
491
+ recheck freshness before granting and report expired decisions via the failure
492
+ observer. `INVITATION_ACCOUNT_CHANGES` in `scripts/diff-source.mjs` reverses
493
+ these exact additions before the previously declared changes and the complete
494
+ comparison with the pinned source. This is an additive public API release,
495
+ 0.11.0; it does not make an unmodified consumer's admission policy safe.
496
+
497
+ ## Authenticated admission refusal (0.12.0)
498
+
499
+ The new `invitation-decline.ts` codec uses a version-3 refusal envelope on the
500
+ existing encrypted reply kind 20467. The version-2 grant body, existing keys,
501
+ derivation labels and invitation URLs are unchanged. A refusal is addressed to
502
+ one request device and event ID, signed by the pinned root or authenticated via
503
+ its current bounded delegation chain; it carries no traffic secret or newly
504
+ issued authority. Bearer possession alone cannot refuse another guest.
505
+ New request helpers stop their immutable retries and erase only the owned
506
+ exchange key on an authenticated refusal. Earlier readers reject this envelope
507
+ as a grant and retain their previous bounded timeout. Host false decisions
508
+ retain their historical silence unless a consumer explicitly publishes the new
509
+ refusal. Concurrent authorised replies use the first valid received outcome; a
510
+ refusal does not revoke an already granted capability. `INVITATION_DECLINE_CHANGES`
511
+ reverses the exact helper import and response handling before all earlier
512
+ declared normalisations and the full pinned-source comparison. Synthetic known
513
+ answers cover the new profile and hostile request, authority and version changes.
514
+
515
+ ## Persistent invitation evidence
516
+
517
+ `persistent-invitation-evidence.ts` is an additive observation/custody API. It
518
+ uses the existing signed group invitation and retirement formats and leaves
519
+ `persistent-invitation.ts` and its legacy request/error behaviour unchanged.
520
+ The evidence path independently rechecks a complete bounded observed set and
521
+ holds on conflicting newest equal-time relay lists; its EOSE receipt does not
522
+ establish global absence or current membership. No storage or client effects
523
+ move into the kit. See `docs/persistent-invitation-evidence.md` for the carrier,
524
+ selection, refusal and caller integration boundaries.
525
+
526
+ ## Quiet automatic replies: declared additions to `epoch.ts` and `invitation.ts`
527
+
528
+ Unreleased. The authority desk waits a random time before every answer and
529
+ rations its refusals, and the temporary-link responder gains an opt-in wait
530
+ and coordinated mode (see `docs/quiet-replies.md`). The shared helpers live
531
+ in `src/quiet-reply.ts`, new and moved from nowhere. No event changes: every
532
+ grant and refusal is encoded as before, so every vector still verifies.
533
+ `hostRoomEpoch` waits by default; `hostRoomInvitation` changes nothing
534
+ unless `grantDelayMs` or `coordinate` is passed. `scripts/diff-source.mjs`
535
+ declares each change in `QUIET_REPLY_CHANGES`, as new text against 0.12.0
536
+ text, applied before every other change set, so the whole-file comparison
537
+ against the pinned commit still holds.
538
+
539
+ ## Live invitation grant retry handling
540
+
541
+ `hostRoomInvitation` now retains a bounded per-request decision and the exact
542
+ signed grant. A fresh duplicate can retry a lost or rejected ephemeral grant
543
+ without another approval, new delegation or repeated success callback. Pending
544
+ approval, quiet waits and publication coalesce duplicates. Every send still
545
+ checks request freshness, current delegated authority and host lifetime. Live
546
+ entries are not evicted to make room; only idle expired entries are pruned.
547
+ This changes local host behaviour, not any encoder or wire format.
548
+ `INVITATION_RETRY_CHANGES` in `scripts/diff-source.mjs` reverses these exact
549
+ hunks before earlier declared changes and the pinned-source comparison.