@cosyte/astm 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -2071,15 +2071,43 @@ declare function attachComments(records: readonly AstmRecord[], warnings: AstmRe
2071
2071
  * The serializer refuses: an embedded `CR`/`LF` is a typed {@link AstmSerializeError},
2072
2072
  * never emitted raw.
2073
2073
  *
2074
+ * **A value keeps the bytes the *frame* layer reserves, on purpose.** `STX`, `ETB`
2075
+ * and `ETX` are deliberately **not** refused here. They break a record only once it
2076
+ * is framed, and this layer returns a `string`, which is not yet on any wire: a
2077
+ * consumer on a raw transport, which is a real ASTM deployment this library models
2078
+ * (`detectFraming`), gets such a value back byte for byte through parse and emit,
2079
+ * measured on every modeled value. Refusing it here would take a
2080
+ * byte the caller genuinely supplied away from consumers who never frame anything.
2081
+ * `composeAstmFrames` is the total gate on the framed route, including through
2082
+ * `serializeFramedAstm`, and refuses it there (`ASTM_FRAME_RESERVED_BYTE`).
2083
+ * `CR`/`LF` are different: they end a *record*, so they corrupt this layer's own
2084
+ * output, which is why they are refused at this one.
2085
+ *
2086
+ * **One position is not a modeled value and does not keep the byte:** the surplus
2087
+ * of a header's delimiter declaration, where any control character is dropped
2088
+ * silently (see `declarationResidual` below, where that disposition is argued and
2089
+ * where it long predates the frame-layer refusal). So "the record layer carries
2090
+ * these bytes" is a statement about values, not about every byte of every line.
2091
+ *
2074
2092
  * **Check the delimiter set before writing.** Three conditions are required for the
2075
2093
  * emitted bytes to read back as the records that produced them (each separator exactly
2076
2094
  * one character, no separator a `CR`/`LF`, no two separators the same character) and a
2077
2095
  * set failing any of them is a typed {@link AstmSerializeError}
2078
2096
  * (`ASTM_EMIT_INVALID_DELIMITERS`) rather than output this library's own parser would
2079
- * reject or silently re-read with a different field tree. These are **necessary**
2080
- * conditions, not a guarantee of readback: a set can pass all three and still produce a
2081
- * stream that reads back wrong, a separator that collides with a record's type letter
2082
- * is the known case, and it corrupts identically with or without this check.
2097
+ * reject or silently re-read with a different field tree. These are conditions on the
2098
+ * **set alone**, so they are not a readback guarantee on their own: a set can pass all
2099
+ * three and still collide with a particular record's type letter, which is why that is
2100
+ * checked per record, on the bytes actually written
2101
+ * (`ASTM_EMIT_TYPE_LETTER_COLLISION`). That one is a **transcoding** condition, not
2102
+ * merely a caller's choice of set, so it is reachable on the default canonical path
2103
+ * too, when a record was read under a set other than the one being emitted.
2104
+ *
2105
+ * **What is still not guaranteed.** Together the two checks guarantee that every
2106
+ * record re-reads as its own *type*. They do not guarantee that every *field* of it
2107
+ * lands where it did: an escape sequence whose body is itself a delimiter is read as
2108
+ * one opaque atom, so that delimiter never becomes a boundary and the fields after it
2109
+ * shift. That is reported on the parse side (`ASTM_UNKNOWN_ESCAPE_SEQUENCE`) and is
2110
+ * not what these refusals cover.
2083
2111
  */
2084
2112
 
2085
2113
  /**
@@ -2104,7 +2132,8 @@ declare class AstmSerializeError extends Error {
2104
2132
  /**
2105
2133
  * Stable discriminant. `ASTM_EMIT_UNENCODABLE_VALUE` for a `CR`/`LF` in a value;
2106
2134
  * `ASTM_EMIT_INVALID_DELIMITERS` for a delimiter set that cannot be emitted
2107
- * reversibly.
2135
+ * reversibly; `ASTM_EMIT_TYPE_LETTER_COLLISION` for a record whose own type
2136
+ * letter the set being emitted with would escape away.
2108
2137
  */
2109
2138
  readonly code: AstmSerializeErrorCode;
2110
2139
  /** 0-based ordinal of the record within the message, when known. */
@@ -2119,8 +2148,14 @@ declare class AstmSerializeError extends Error {
2119
2148
  * (`CR`/`LF`), which the escape codec has no mnemonic for.
2120
2149
  * - `ASTM_EMIT_INVALID_DELIMITERS`: the delimiter set to emit against failed one
2121
2150
  * of the three conditions readback requires (see {@link serializeAstmRecords}).
2122
- * Those conditions are necessary rather than sufficient, so this code means a
2151
+ * Those conditions are checked against the *set alone*, so this code means a
2123
2152
  * set was rejected, not that every unreversible set is.
2153
+ * - `ASTM_EMIT_TYPE_LETTER_COLLISION`: this record's type letter would not be the
2154
+ * first character of its own emitted line, so the record would read back as a
2155
+ * **different** record. Raised per record rather than per set, because whether
2156
+ * a set collides depends on which record is being written: a record read under
2157
+ * one delimiter set and written under another can hit it with **no** set passed
2158
+ * at all, so it is not avoided by emitting on the default canonical path.
2124
2159
  *
2125
2160
  * @example
2126
2161
  * ```ts
@@ -2128,7 +2163,7 @@ declare class AstmSerializeError extends Error {
2128
2163
  * const code: AstmSerializeErrorCode = "ASTM_EMIT_INVALID_DELIMITERS";
2129
2164
  * ```
2130
2165
  */
2131
- type AstmSerializeErrorCode = "ASTM_EMIT_UNENCODABLE_VALUE" | "ASTM_EMIT_INVALID_DELIMITERS";
2166
+ type AstmSerializeErrorCode = "ASTM_EMIT_UNENCODABLE_VALUE" | "ASTM_EMIT_INVALID_DELIMITERS" | "ASTM_EMIT_TYPE_LETTER_COLLISION";
2132
2167
  /**
2133
2168
  * Escape-encode one component leaf for spec-clean emit: the inverse of
2134
2169
  * `decodeEscapes`. The **escape character itself is encoded first** (`&` → `&E&`)
@@ -2146,8 +2181,10 @@ type AstmSerializeErrorCode = "ASTM_EMIT_UNENCODABLE_VALUE" | "ASTM_EMIT_INVALID
2146
2181
  * @returns The escaped component text.
2147
2182
  * @throws {@link AstmSerializeError} for a `CR`/`LF` in the leaf
2148
2183
  * (`ASTM_EMIT_UNENCODABLE_VALUE`), or for a delimiter set failing one of the
2149
- * three conditions readback requires (`ASTM_EMIT_INVALID_DELIMITERS`), which
2150
- * are necessary, not sufficient, so a set can pass them and still not reverse.
2184
+ * three conditions readback requires (`ASTM_EMIT_INVALID_DELIMITERS`). This
2185
+ * helper takes a leaf and no record, so it never raises
2186
+ * `ASTM_EMIT_TYPE_LETTER_COLLISION`: a caller assembling a record line out of it
2187
+ * is outside that check and gets no guarantee that a type letter survives.
2151
2188
  * @example
2152
2189
  * ```ts
2153
2190
  * import { encodeComponent, CANONICAL_DELIMITERS } from "@cosyte/astm";
@@ -2171,9 +2208,13 @@ declare function encodeComponent(leaf: string, d: Delimiters, recordIndex?: numb
2171
2208
  * @param d - The delimiters to emit against; defaults to `H|\^&`.
2172
2209
  * @returns The record's wire text, terminator excluded.
2173
2210
  * @throws {@link AstmSerializeError} when a component contains an unencodable `CR`/`LF`
2174
- * (`ASTM_EMIT_UNENCODABLE_VALUE`), or when `d` fails one of the three conditions
2175
- * readback requires (`ASTM_EMIT_INVALID_DELIMITERS`): necessary conditions, not
2176
- * sufficient ones, so a set can pass them and still not reverse.
2211
+ * (`ASTM_EMIT_UNENCODABLE_VALUE`), when `d` fails one of the three conditions
2212
+ * readback requires (`ASTM_EMIT_INVALID_DELIMITERS`), or when this record's type
2213
+ * letter would not be the first character of its own emitted line
2214
+ * (`ASTM_EMIT_TYPE_LETTER_COLLISION`), which would make it read back as a
2215
+ * different record. That last one is a transcoding condition and needs no `d`
2216
+ * argument to fire: a record read under a different delimiter set can carry a
2217
+ * type letter the canonical set escapes away.
2177
2218
  * @example
2178
2219
  * ```ts
2179
2220
  * import { serializeAstmRecord, parseAstmRecords } from "@cosyte/astm";
@@ -2202,18 +2243,29 @@ declare function serializeAstmRecord(record: AstmRecord, d?: Delimiters): string
2202
2243
  * fails is an {@link AstmSerializeError} with code `ASTM_EMIT_INVALID_DELIMITERS`.
2203
2244
  * This is stricter than the parser, which reads some sets it cannot reverse, so
2204
2245
  * `serializeAstmRecords(msg, msg.delimiters)` can refuse a message that parsed:
2205
- * in exactly the cases where it used to emit a stream that read back wrong. The
2206
- * three conditions are necessary rather than sufficient: a set that passes them can
2207
- * still read back wrong if a separator collides with a record's type letter, which
2208
- * this check does not claim to catch.
2246
+ * in exactly the cases where it used to emit a stream that read back wrong.
2247
+ *
2248
+ * **And each record is checked against the set it is being written with.** Those
2249
+ * three conditions read the set alone, so they cannot see that `field` = `R`
2250
+ * escapes an `R` record's own type letter away. Every record's emitted line is
2251
+ * therefore checked to start with the letter the record models, and one that does
2252
+ * not is `ASTM_EMIT_TYPE_LETTER_COLLISION` rather than a stream that reads back as
2253
+ * different records. What neither check promises is that every *field* lands where
2254
+ * it did: an escape sequence whose body is a delimiter is an opaque atom, so that
2255
+ * delimiter never becomes a boundary, and the parse side reports that
2256
+ * (`ASTM_UNKNOWN_ESCAPE_SEQUENCE`) rather than emit refusing it.
2209
2257
  *
2210
2258
  * @param input - A parsed {@link AstmMessage} or a list of {@link AstmRecord}s.
2211
2259
  * @param d - The delimiters to emit against; defaults to the canonical `H|\^&` set.
2212
2260
  * @returns The serialized record stream (`CR` after every record).
2213
2261
  * @throws {@link AstmSerializeError} when a component contains an unencodable `CR`/`LF`
2214
- * (`ASTM_EMIT_UNENCODABLE_VALUE`), or when `d` fails one of the three conditions
2215
- * readback requires (`ASTM_EMIT_INVALID_DELIMITERS`): necessary conditions, not
2216
- * sufficient ones, so a set can pass them and still not reverse.
2262
+ * (`ASTM_EMIT_UNENCODABLE_VALUE`), when `d` fails one of the three conditions
2263
+ * readback requires (`ASTM_EMIT_INVALID_DELIMITERS`), or when a record's type
2264
+ * letter would not be the first character of its own emitted line
2265
+ * (`ASTM_EMIT_TYPE_LETTER_COLLISION`). The last of the three fires on the
2266
+ * **default** canonical path as well: it compares each record against the set
2267
+ * being emitted with, so a record read under a different set can carry a type
2268
+ * letter the canonical set escapes away. Omitting `d` is not a way around it.
2217
2269
  * @example
2218
2270
  * ```ts
2219
2271
  * import { parseAstmRecords, serializeAstmRecords } from "@cosyte/astm";
@@ -2232,8 +2284,10 @@ declare function serializeAstmRecords(input: AstmMessage | readonly AstmRecord[]
2232
2284
  * @returns The escaped field text.
2233
2285
  * @throws {@link AstmSerializeError} when a component contains an unencodable `CR`/`LF`
2234
2286
  * (`ASTM_EMIT_UNENCODABLE_VALUE`), or when `d` fails one of the three conditions
2235
- * readback requires (`ASTM_EMIT_INVALID_DELIMITERS`): necessary conditions, not
2236
- * sufficient ones, so a set can pass them and still not reverse.
2287
+ * readback requires (`ASTM_EMIT_INVALID_DELIMITERS`). Like
2288
+ * {@link encodeComponent} this takes no record, so it never raises
2289
+ * `ASTM_EMIT_TYPE_LETTER_COLLISION`: encoding a record's type-letter field
2290
+ * through it will escape that letter away without objecting.
2237
2291
  * @example
2238
2292
  * ```ts
2239
2293
  * import { serializeField, tokenizeRecord, CANONICAL_DELIMITERS } from "@cosyte/astm";
@@ -4375,6 +4429,9 @@ declare function decodeAstmFrames(bytes: Uint8Array, options?: FrameOptions): De
4375
4429
  * empty frame.
4376
4430
  * - `ASTM_FRAME_UNENCODABLE_CHARACTER`: a record given as a `string` holds a
4377
4431
  * character above `U+00FF`, which has no single byte to stand for.
4432
+ * - `ASTM_FRAME_RESERVED_BYTE`: a record holds a byte this layer reads as frame
4433
+ * structure (`STX`, `ETB` or `ETX`). Framing has no escape mechanism, so such
4434
+ * a byte cannot be carried inside a frame at all.
4378
4435
  *
4379
4436
  * @example
4380
4437
  * ```ts
@@ -4382,7 +4439,7 @@ declare function decodeAstmFrames(bytes: Uint8Array, options?: FrameOptions): De
4382
4439
  * const code: AstmFrameEncodeErrorCode = "ASTM_FRAME_UNENCODABLE_CHARACTER";
4383
4440
  * ```
4384
4441
  */
4385
- type AstmFrameEncodeErrorCode = "ASTM_FRAME_EMPTY_RECORD" | "ASTM_FRAME_UNENCODABLE_CHARACTER";
4442
+ type AstmFrameEncodeErrorCode = "ASTM_FRAME_EMPTY_RECORD" | "ASTM_FRAME_UNENCODABLE_CHARACTER" | "ASTM_FRAME_RESERVED_BYTE";
4386
4443
  /**
4387
4444
  * Thrown by {@link composeAstmFrames} when the input cannot be framed into a
4388
4445
  * spec-clean stream. Carries a stable code + positional context, never the
@@ -4404,6 +4461,16 @@ type AstmFrameEncodeErrorCode = "ASTM_FRAME_EMPTY_RECORD" | "ASTM_FRAME_UNENCODA
4404
4461
  * The refusal removes no capability, it routes you to the parameter that already
4405
4462
  * carried it.
4406
4463
  *
4464
+ * **The reserved-byte case, and why it has no escape hatch.** `STX`, `ETB` and
4465
+ * `ETX` are what {@link decodeAstmFrames} reads as frame structure: `STX` opens
4466
+ * a frame, and the first `ETB`/`ETX` after it *is* the end of that frame's text.
4467
+ * A record carrying one of those bytes therefore cannot be framed and read back,
4468
+ * whichever form it arrives in, and this layer has no escape sequence to hide one
4469
+ * behind. Passing bytes instead of a string does **not** route around it, because
4470
+ * the byte is the problem rather than the encoding. Take the byte out of the value
4471
+ * before framing: which byte belongs in a clinical value is the sender's call, not
4472
+ * this library's, so it refuses rather than substituting or deleting one.
4473
+ *
4407
4474
  * @example
4408
4475
  * ```ts
4409
4476
  * import { composeAstmFrames, AstmFrameEncodeError } from "@cosyte/astm";
@@ -4422,7 +4489,9 @@ declare class AstmFrameEncodeError extends Error {
4422
4489
  /**
4423
4490
  * Position of the offending character within that record, when applicable:
4424
4491
  * its index in the string, never the character itself and never its code
4425
- * point. Enough to find it in the caller's own data.
4492
+ * point. Enough to find it in the caller's own data. For a record supplied as
4493
+ * a `Uint8Array` this is the offending byte's index, which is the same
4494
+ * position: a record string is a byte string, so the two indices coincide.
4426
4495
  */
4427
4496
  readonly characterIndex?: number;
4428
4497
  /** @internal */
@@ -4456,20 +4525,32 @@ interface ComposeFramesOptions {
4456
4525
  * resulting `Uint8Array`. Those two forms are the whole of what `records` accepts.
4457
4526
  * A caller reaching this from JavaScript with some **other** typed array is outside
4458
4527
  * the signature and gets no such refusal: each element is still written as its low
4459
- * byte, the same substitution described above. Pass a `Uint8Array` or a string.
4460
- *
4461
- * That covers the string-to-bytes step only. It is **not** a guarantee that any
4462
- * accepted record reads back: a record already carrying an `STX`, `ETX` or `ETB`
4463
- * byte is framed as given and re-decodes wrong, which this refusal does not touch
4464
- * and does not claim to.
4528
+ * byte, the same substitution described above. The reserved-byte check below does
4529
+ * not reach that route either, because it compares elements against the three byte
4530
+ * values and an element like `0x0103` is not one of them, though its low byte
4531
+ * becomes an `ETX` on the wire. Pass a `Uint8Array` or a string.
4532
+ *
4533
+ * **A record carrying a frame-structure byte is refused too, in either form.**
4534
+ * `STX`, `ETB` and `ETX` are what the decoder reads as the shape of a frame, and
4535
+ * framing has no escape sequence to hide one behind, so a record holding one is
4536
+ * `ASTM_FRAME_RESERVED_BYTE` rather than a frame truncated at that byte. It used
4537
+ * to be written as given, and the result was not reliably loud: with the two
4538
+ * following bytes matching, the truncated frame verifies and a whole record is
4539
+ * absorbed into the previous one with an empty warnings array at both layers.
4540
+ * Supplying the record as a `Uint8Array` does not route around this check.
4541
+ *
4542
+ * What is still **not** guaranteed: that an accepted record round-trips through
4543
+ * the *record* layer. A delimiter colliding with a record's type letter, for one,
4544
+ * frames and de-frames byte-exactly and still re-reads as a different record.
4465
4545
  *
4466
4546
  * @param records - The reassembled record byte-strings (`Uint8Array` or latin1
4467
4547
  * `string`), one entry per complete record.
4468
4548
  * @param options - Encode options.
4469
4549
  * @returns The framed byte stream.
4470
4550
  * @throws {@link AstmFrameEncodeError} when the list is empty, a record has no
4471
- * bytes (`ASTM_FRAME_EMPTY_RECORD`), or a record string holds a character above
4472
- * `U+00FF` (`ASTM_FRAME_UNENCODABLE_CHARACTER`).
4551
+ * bytes (`ASTM_FRAME_EMPTY_RECORD`), a record string holds a character above
4552
+ * `U+00FF` (`ASTM_FRAME_UNENCODABLE_CHARACTER`), or a record holds an `STX`,
4553
+ * `ETB` or `ETX` byte (`ASTM_FRAME_RESERVED_BYTE`).
4473
4554
  * @example
4474
4555
  * ```ts
4475
4556
  * import { composeAstmFrames, decodeAstmFrames } from "@cosyte/astm";
@@ -4548,16 +4629,26 @@ declare function parseFramedAstm(bytes: Uint8Array, options?: FrameOptions & Ast
4548
4629
  *
4549
4630
  * A value carrying a character above `U+00FF` is **refused** here rather than
4550
4631
  * framed: the record layer is happy to hold one, but a frame carries bytes and
4551
- * nothing in the message says which character encoding to turn it into. A message
4552
- * whose every value is Latin-1 is unaffected.
4632
+ * nothing in the message says which character encoding to turn it into.
4633
+ *
4634
+ * A value carrying a raw `STX`, `ETB` or `ETX` is refused too, and being Latin-1
4635
+ * is no exemption: those three are what {@link decodeAstmFrames} reads as the
4636
+ * shape of a frame, and framing has no escape sequence to hide one behind. The
4637
+ * record layer is happy to hold those as well, so this is a message that
4638
+ * serializes to records perfectly well and cannot be framed.
4553
4639
  *
4554
4640
  * @param input - A parsed {@link AstmMessage} or a list of {@link AstmRecord}s.
4555
4641
  * @param options - Frame-encode options.
4556
4642
  * @returns The framed byte stream.
4557
- * @throws {@link AstmSerializeError} when a value contains an unencodable `CR`/`LF`.
4643
+ * @throws {@link AstmSerializeError} when a value contains an unencodable `CR`/`LF`
4644
+ * (`ASTM_EMIT_UNENCODABLE_VALUE`), or when a record's own type letter would not
4645
+ * survive being written in the canonical set this function serializes with
4646
+ * (`ASTM_EMIT_TYPE_LETTER_COLLISION`). The second reaches messages parsed under a
4647
+ * **different** delimiter set, since this function passes no set of its own.
4558
4648
  * @throws {@link AstmFrameEncodeError} when there are no records to frame
4559
- * (`ASTM_FRAME_EMPTY_RECORD`), or a value holds a character above `U+00FF`
4560
- * (`ASTM_FRAME_UNENCODABLE_CHARACTER`).
4649
+ * (`ASTM_FRAME_EMPTY_RECORD`), a value holds a character above `U+00FF`
4650
+ * (`ASTM_FRAME_UNENCODABLE_CHARACTER`), or a record holds an `STX`, `ETB` or
4651
+ * `ETX` byte (`ASTM_FRAME_RESERVED_BYTE`).
4561
4652
  * @example
4562
4653
  * ```ts
4563
4654
  * import { parseAstmRecords, serializeFramedAstm, parseFramedAstm } from "@cosyte/astm";
package/dist/index.d.ts CHANGED
@@ -2071,15 +2071,43 @@ declare function attachComments(records: readonly AstmRecord[], warnings: AstmRe
2071
2071
  * The serializer refuses: an embedded `CR`/`LF` is a typed {@link AstmSerializeError},
2072
2072
  * never emitted raw.
2073
2073
  *
2074
+ * **A value keeps the bytes the *frame* layer reserves, on purpose.** `STX`, `ETB`
2075
+ * and `ETX` are deliberately **not** refused here. They break a record only once it
2076
+ * is framed, and this layer returns a `string`, which is not yet on any wire: a
2077
+ * consumer on a raw transport, which is a real ASTM deployment this library models
2078
+ * (`detectFraming`), gets such a value back byte for byte through parse and emit,
2079
+ * measured on every modeled value. Refusing it here would take a
2080
+ * byte the caller genuinely supplied away from consumers who never frame anything.
2081
+ * `composeAstmFrames` is the total gate on the framed route, including through
2082
+ * `serializeFramedAstm`, and refuses it there (`ASTM_FRAME_RESERVED_BYTE`).
2083
+ * `CR`/`LF` are different: they end a *record*, so they corrupt this layer's own
2084
+ * output, which is why they are refused at this one.
2085
+ *
2086
+ * **One position is not a modeled value and does not keep the byte:** the surplus
2087
+ * of a header's delimiter declaration, where any control character is dropped
2088
+ * silently (see `declarationResidual` below, where that disposition is argued and
2089
+ * where it long predates the frame-layer refusal). So "the record layer carries
2090
+ * these bytes" is a statement about values, not about every byte of every line.
2091
+ *
2074
2092
  * **Check the delimiter set before writing.** Three conditions are required for the
2075
2093
  * emitted bytes to read back as the records that produced them (each separator exactly
2076
2094
  * one character, no separator a `CR`/`LF`, no two separators the same character) and a
2077
2095
  * set failing any of them is a typed {@link AstmSerializeError}
2078
2096
  * (`ASTM_EMIT_INVALID_DELIMITERS`) rather than output this library's own parser would
2079
- * reject or silently re-read with a different field tree. These are **necessary**
2080
- * conditions, not a guarantee of readback: a set can pass all three and still produce a
2081
- * stream that reads back wrong, a separator that collides with a record's type letter
2082
- * is the known case, and it corrupts identically with or without this check.
2097
+ * reject or silently re-read with a different field tree. These are conditions on the
2098
+ * **set alone**, so they are not a readback guarantee on their own: a set can pass all
2099
+ * three and still collide with a particular record's type letter, which is why that is
2100
+ * checked per record, on the bytes actually written
2101
+ * (`ASTM_EMIT_TYPE_LETTER_COLLISION`). That one is a **transcoding** condition, not
2102
+ * merely a caller's choice of set, so it is reachable on the default canonical path
2103
+ * too, when a record was read under a set other than the one being emitted.
2104
+ *
2105
+ * **What is still not guaranteed.** Together the two checks guarantee that every
2106
+ * record re-reads as its own *type*. They do not guarantee that every *field* of it
2107
+ * lands where it did: an escape sequence whose body is itself a delimiter is read as
2108
+ * one opaque atom, so that delimiter never becomes a boundary and the fields after it
2109
+ * shift. That is reported on the parse side (`ASTM_UNKNOWN_ESCAPE_SEQUENCE`) and is
2110
+ * not what these refusals cover.
2083
2111
  */
2084
2112
 
2085
2113
  /**
@@ -2104,7 +2132,8 @@ declare class AstmSerializeError extends Error {
2104
2132
  /**
2105
2133
  * Stable discriminant. `ASTM_EMIT_UNENCODABLE_VALUE` for a `CR`/`LF` in a value;
2106
2134
  * `ASTM_EMIT_INVALID_DELIMITERS` for a delimiter set that cannot be emitted
2107
- * reversibly.
2135
+ * reversibly; `ASTM_EMIT_TYPE_LETTER_COLLISION` for a record whose own type
2136
+ * letter the set being emitted with would escape away.
2108
2137
  */
2109
2138
  readonly code: AstmSerializeErrorCode;
2110
2139
  /** 0-based ordinal of the record within the message, when known. */
@@ -2119,8 +2148,14 @@ declare class AstmSerializeError extends Error {
2119
2148
  * (`CR`/`LF`), which the escape codec has no mnemonic for.
2120
2149
  * - `ASTM_EMIT_INVALID_DELIMITERS`: the delimiter set to emit against failed one
2121
2150
  * of the three conditions readback requires (see {@link serializeAstmRecords}).
2122
- * Those conditions are necessary rather than sufficient, so this code means a
2151
+ * Those conditions are checked against the *set alone*, so this code means a
2123
2152
  * set was rejected, not that every unreversible set is.
2153
+ * - `ASTM_EMIT_TYPE_LETTER_COLLISION`: this record's type letter would not be the
2154
+ * first character of its own emitted line, so the record would read back as a
2155
+ * **different** record. Raised per record rather than per set, because whether
2156
+ * a set collides depends on which record is being written: a record read under
2157
+ * one delimiter set and written under another can hit it with **no** set passed
2158
+ * at all, so it is not avoided by emitting on the default canonical path.
2124
2159
  *
2125
2160
  * @example
2126
2161
  * ```ts
@@ -2128,7 +2163,7 @@ declare class AstmSerializeError extends Error {
2128
2163
  * const code: AstmSerializeErrorCode = "ASTM_EMIT_INVALID_DELIMITERS";
2129
2164
  * ```
2130
2165
  */
2131
- type AstmSerializeErrorCode = "ASTM_EMIT_UNENCODABLE_VALUE" | "ASTM_EMIT_INVALID_DELIMITERS";
2166
+ type AstmSerializeErrorCode = "ASTM_EMIT_UNENCODABLE_VALUE" | "ASTM_EMIT_INVALID_DELIMITERS" | "ASTM_EMIT_TYPE_LETTER_COLLISION";
2132
2167
  /**
2133
2168
  * Escape-encode one component leaf for spec-clean emit: the inverse of
2134
2169
  * `decodeEscapes`. The **escape character itself is encoded first** (`&` → `&E&`)
@@ -2146,8 +2181,10 @@ type AstmSerializeErrorCode = "ASTM_EMIT_UNENCODABLE_VALUE" | "ASTM_EMIT_INVALID
2146
2181
  * @returns The escaped component text.
2147
2182
  * @throws {@link AstmSerializeError} for a `CR`/`LF` in the leaf
2148
2183
  * (`ASTM_EMIT_UNENCODABLE_VALUE`), or for a delimiter set failing one of the
2149
- * three conditions readback requires (`ASTM_EMIT_INVALID_DELIMITERS`), which
2150
- * are necessary, not sufficient, so a set can pass them and still not reverse.
2184
+ * three conditions readback requires (`ASTM_EMIT_INVALID_DELIMITERS`). This
2185
+ * helper takes a leaf and no record, so it never raises
2186
+ * `ASTM_EMIT_TYPE_LETTER_COLLISION`: a caller assembling a record line out of it
2187
+ * is outside that check and gets no guarantee that a type letter survives.
2151
2188
  * @example
2152
2189
  * ```ts
2153
2190
  * import { encodeComponent, CANONICAL_DELIMITERS } from "@cosyte/astm";
@@ -2171,9 +2208,13 @@ declare function encodeComponent(leaf: string, d: Delimiters, recordIndex?: numb
2171
2208
  * @param d - The delimiters to emit against; defaults to `H|\^&`.
2172
2209
  * @returns The record's wire text, terminator excluded.
2173
2210
  * @throws {@link AstmSerializeError} when a component contains an unencodable `CR`/`LF`
2174
- * (`ASTM_EMIT_UNENCODABLE_VALUE`), or when `d` fails one of the three conditions
2175
- * readback requires (`ASTM_EMIT_INVALID_DELIMITERS`): necessary conditions, not
2176
- * sufficient ones, so a set can pass them and still not reverse.
2211
+ * (`ASTM_EMIT_UNENCODABLE_VALUE`), when `d` fails one of the three conditions
2212
+ * readback requires (`ASTM_EMIT_INVALID_DELIMITERS`), or when this record's type
2213
+ * letter would not be the first character of its own emitted line
2214
+ * (`ASTM_EMIT_TYPE_LETTER_COLLISION`), which would make it read back as a
2215
+ * different record. That last one is a transcoding condition and needs no `d`
2216
+ * argument to fire: a record read under a different delimiter set can carry a
2217
+ * type letter the canonical set escapes away.
2177
2218
  * @example
2178
2219
  * ```ts
2179
2220
  * import { serializeAstmRecord, parseAstmRecords } from "@cosyte/astm";
@@ -2202,18 +2243,29 @@ declare function serializeAstmRecord(record: AstmRecord, d?: Delimiters): string
2202
2243
  * fails is an {@link AstmSerializeError} with code `ASTM_EMIT_INVALID_DELIMITERS`.
2203
2244
  * This is stricter than the parser, which reads some sets it cannot reverse, so
2204
2245
  * `serializeAstmRecords(msg, msg.delimiters)` can refuse a message that parsed:
2205
- * in exactly the cases where it used to emit a stream that read back wrong. The
2206
- * three conditions are necessary rather than sufficient: a set that passes them can
2207
- * still read back wrong if a separator collides with a record's type letter, which
2208
- * this check does not claim to catch.
2246
+ * in exactly the cases where it used to emit a stream that read back wrong.
2247
+ *
2248
+ * **And each record is checked against the set it is being written with.** Those
2249
+ * three conditions read the set alone, so they cannot see that `field` = `R`
2250
+ * escapes an `R` record's own type letter away. Every record's emitted line is
2251
+ * therefore checked to start with the letter the record models, and one that does
2252
+ * not is `ASTM_EMIT_TYPE_LETTER_COLLISION` rather than a stream that reads back as
2253
+ * different records. What neither check promises is that every *field* lands where
2254
+ * it did: an escape sequence whose body is a delimiter is an opaque atom, so that
2255
+ * delimiter never becomes a boundary, and the parse side reports that
2256
+ * (`ASTM_UNKNOWN_ESCAPE_SEQUENCE`) rather than emit refusing it.
2209
2257
  *
2210
2258
  * @param input - A parsed {@link AstmMessage} or a list of {@link AstmRecord}s.
2211
2259
  * @param d - The delimiters to emit against; defaults to the canonical `H|\^&` set.
2212
2260
  * @returns The serialized record stream (`CR` after every record).
2213
2261
  * @throws {@link AstmSerializeError} when a component contains an unencodable `CR`/`LF`
2214
- * (`ASTM_EMIT_UNENCODABLE_VALUE`), or when `d` fails one of the three conditions
2215
- * readback requires (`ASTM_EMIT_INVALID_DELIMITERS`): necessary conditions, not
2216
- * sufficient ones, so a set can pass them and still not reverse.
2262
+ * (`ASTM_EMIT_UNENCODABLE_VALUE`), when `d` fails one of the three conditions
2263
+ * readback requires (`ASTM_EMIT_INVALID_DELIMITERS`), or when a record's type
2264
+ * letter would not be the first character of its own emitted line
2265
+ * (`ASTM_EMIT_TYPE_LETTER_COLLISION`). The last of the three fires on the
2266
+ * **default** canonical path as well: it compares each record against the set
2267
+ * being emitted with, so a record read under a different set can carry a type
2268
+ * letter the canonical set escapes away. Omitting `d` is not a way around it.
2217
2269
  * @example
2218
2270
  * ```ts
2219
2271
  * import { parseAstmRecords, serializeAstmRecords } from "@cosyte/astm";
@@ -2232,8 +2284,10 @@ declare function serializeAstmRecords(input: AstmMessage | readonly AstmRecord[]
2232
2284
  * @returns The escaped field text.
2233
2285
  * @throws {@link AstmSerializeError} when a component contains an unencodable `CR`/`LF`
2234
2286
  * (`ASTM_EMIT_UNENCODABLE_VALUE`), or when `d` fails one of the three conditions
2235
- * readback requires (`ASTM_EMIT_INVALID_DELIMITERS`): necessary conditions, not
2236
- * sufficient ones, so a set can pass them and still not reverse.
2287
+ * readback requires (`ASTM_EMIT_INVALID_DELIMITERS`). Like
2288
+ * {@link encodeComponent} this takes no record, so it never raises
2289
+ * `ASTM_EMIT_TYPE_LETTER_COLLISION`: encoding a record's type-letter field
2290
+ * through it will escape that letter away without objecting.
2237
2291
  * @example
2238
2292
  * ```ts
2239
2293
  * import { serializeField, tokenizeRecord, CANONICAL_DELIMITERS } from "@cosyte/astm";
@@ -4375,6 +4429,9 @@ declare function decodeAstmFrames(bytes: Uint8Array, options?: FrameOptions): De
4375
4429
  * empty frame.
4376
4430
  * - `ASTM_FRAME_UNENCODABLE_CHARACTER`: a record given as a `string` holds a
4377
4431
  * character above `U+00FF`, which has no single byte to stand for.
4432
+ * - `ASTM_FRAME_RESERVED_BYTE`: a record holds a byte this layer reads as frame
4433
+ * structure (`STX`, `ETB` or `ETX`). Framing has no escape mechanism, so such
4434
+ * a byte cannot be carried inside a frame at all.
4378
4435
  *
4379
4436
  * @example
4380
4437
  * ```ts
@@ -4382,7 +4439,7 @@ declare function decodeAstmFrames(bytes: Uint8Array, options?: FrameOptions): De
4382
4439
  * const code: AstmFrameEncodeErrorCode = "ASTM_FRAME_UNENCODABLE_CHARACTER";
4383
4440
  * ```
4384
4441
  */
4385
- type AstmFrameEncodeErrorCode = "ASTM_FRAME_EMPTY_RECORD" | "ASTM_FRAME_UNENCODABLE_CHARACTER";
4442
+ type AstmFrameEncodeErrorCode = "ASTM_FRAME_EMPTY_RECORD" | "ASTM_FRAME_UNENCODABLE_CHARACTER" | "ASTM_FRAME_RESERVED_BYTE";
4386
4443
  /**
4387
4444
  * Thrown by {@link composeAstmFrames} when the input cannot be framed into a
4388
4445
  * spec-clean stream. Carries a stable code + positional context, never the
@@ -4404,6 +4461,16 @@ type AstmFrameEncodeErrorCode = "ASTM_FRAME_EMPTY_RECORD" | "ASTM_FRAME_UNENCODA
4404
4461
  * The refusal removes no capability, it routes you to the parameter that already
4405
4462
  * carried it.
4406
4463
  *
4464
+ * **The reserved-byte case, and why it has no escape hatch.** `STX`, `ETB` and
4465
+ * `ETX` are what {@link decodeAstmFrames} reads as frame structure: `STX` opens
4466
+ * a frame, and the first `ETB`/`ETX` after it *is* the end of that frame's text.
4467
+ * A record carrying one of those bytes therefore cannot be framed and read back,
4468
+ * whichever form it arrives in, and this layer has no escape sequence to hide one
4469
+ * behind. Passing bytes instead of a string does **not** route around it, because
4470
+ * the byte is the problem rather than the encoding. Take the byte out of the value
4471
+ * before framing: which byte belongs in a clinical value is the sender's call, not
4472
+ * this library's, so it refuses rather than substituting or deleting one.
4473
+ *
4407
4474
  * @example
4408
4475
  * ```ts
4409
4476
  * import { composeAstmFrames, AstmFrameEncodeError } from "@cosyte/astm";
@@ -4422,7 +4489,9 @@ declare class AstmFrameEncodeError extends Error {
4422
4489
  /**
4423
4490
  * Position of the offending character within that record, when applicable:
4424
4491
  * its index in the string, never the character itself and never its code
4425
- * point. Enough to find it in the caller's own data.
4492
+ * point. Enough to find it in the caller's own data. For a record supplied as
4493
+ * a `Uint8Array` this is the offending byte's index, which is the same
4494
+ * position: a record string is a byte string, so the two indices coincide.
4426
4495
  */
4427
4496
  readonly characterIndex?: number;
4428
4497
  /** @internal */
@@ -4456,20 +4525,32 @@ interface ComposeFramesOptions {
4456
4525
  * resulting `Uint8Array`. Those two forms are the whole of what `records` accepts.
4457
4526
  * A caller reaching this from JavaScript with some **other** typed array is outside
4458
4527
  * the signature and gets no such refusal: each element is still written as its low
4459
- * byte, the same substitution described above. Pass a `Uint8Array` or a string.
4460
- *
4461
- * That covers the string-to-bytes step only. It is **not** a guarantee that any
4462
- * accepted record reads back: a record already carrying an `STX`, `ETX` or `ETB`
4463
- * byte is framed as given and re-decodes wrong, which this refusal does not touch
4464
- * and does not claim to.
4528
+ * byte, the same substitution described above. The reserved-byte check below does
4529
+ * not reach that route either, because it compares elements against the three byte
4530
+ * values and an element like `0x0103` is not one of them, though its low byte
4531
+ * becomes an `ETX` on the wire. Pass a `Uint8Array` or a string.
4532
+ *
4533
+ * **A record carrying a frame-structure byte is refused too, in either form.**
4534
+ * `STX`, `ETB` and `ETX` are what the decoder reads as the shape of a frame, and
4535
+ * framing has no escape sequence to hide one behind, so a record holding one is
4536
+ * `ASTM_FRAME_RESERVED_BYTE` rather than a frame truncated at that byte. It used
4537
+ * to be written as given, and the result was not reliably loud: with the two
4538
+ * following bytes matching, the truncated frame verifies and a whole record is
4539
+ * absorbed into the previous one with an empty warnings array at both layers.
4540
+ * Supplying the record as a `Uint8Array` does not route around this check.
4541
+ *
4542
+ * What is still **not** guaranteed: that an accepted record round-trips through
4543
+ * the *record* layer. A delimiter colliding with a record's type letter, for one,
4544
+ * frames and de-frames byte-exactly and still re-reads as a different record.
4465
4545
  *
4466
4546
  * @param records - The reassembled record byte-strings (`Uint8Array` or latin1
4467
4547
  * `string`), one entry per complete record.
4468
4548
  * @param options - Encode options.
4469
4549
  * @returns The framed byte stream.
4470
4550
  * @throws {@link AstmFrameEncodeError} when the list is empty, a record has no
4471
- * bytes (`ASTM_FRAME_EMPTY_RECORD`), or a record string holds a character above
4472
- * `U+00FF` (`ASTM_FRAME_UNENCODABLE_CHARACTER`).
4551
+ * bytes (`ASTM_FRAME_EMPTY_RECORD`), a record string holds a character above
4552
+ * `U+00FF` (`ASTM_FRAME_UNENCODABLE_CHARACTER`), or a record holds an `STX`,
4553
+ * `ETB` or `ETX` byte (`ASTM_FRAME_RESERVED_BYTE`).
4473
4554
  * @example
4474
4555
  * ```ts
4475
4556
  * import { composeAstmFrames, decodeAstmFrames } from "@cosyte/astm";
@@ -4548,16 +4629,26 @@ declare function parseFramedAstm(bytes: Uint8Array, options?: FrameOptions & Ast
4548
4629
  *
4549
4630
  * A value carrying a character above `U+00FF` is **refused** here rather than
4550
4631
  * framed: the record layer is happy to hold one, but a frame carries bytes and
4551
- * nothing in the message says which character encoding to turn it into. A message
4552
- * whose every value is Latin-1 is unaffected.
4632
+ * nothing in the message says which character encoding to turn it into.
4633
+ *
4634
+ * A value carrying a raw `STX`, `ETB` or `ETX` is refused too, and being Latin-1
4635
+ * is no exemption: those three are what {@link decodeAstmFrames} reads as the
4636
+ * shape of a frame, and framing has no escape sequence to hide one behind. The
4637
+ * record layer is happy to hold those as well, so this is a message that
4638
+ * serializes to records perfectly well and cannot be framed.
4553
4639
  *
4554
4640
  * @param input - A parsed {@link AstmMessage} or a list of {@link AstmRecord}s.
4555
4641
  * @param options - Frame-encode options.
4556
4642
  * @returns The framed byte stream.
4557
- * @throws {@link AstmSerializeError} when a value contains an unencodable `CR`/`LF`.
4643
+ * @throws {@link AstmSerializeError} when a value contains an unencodable `CR`/`LF`
4644
+ * (`ASTM_EMIT_UNENCODABLE_VALUE`), or when a record's own type letter would not
4645
+ * survive being written in the canonical set this function serializes with
4646
+ * (`ASTM_EMIT_TYPE_LETTER_COLLISION`). The second reaches messages parsed under a
4647
+ * **different** delimiter set, since this function passes no set of its own.
4558
4648
  * @throws {@link AstmFrameEncodeError} when there are no records to frame
4559
- * (`ASTM_FRAME_EMPTY_RECORD`), or a value holds a character above `U+00FF`
4560
- * (`ASTM_FRAME_UNENCODABLE_CHARACTER`).
4649
+ * (`ASTM_FRAME_EMPTY_RECORD`), a value holds a character above `U+00FF`
4650
+ * (`ASTM_FRAME_UNENCODABLE_CHARACTER`), or a record holds an `STX`, `ETB` or
4651
+ * `ETX` byte (`ASTM_FRAME_RESERVED_BYTE`).
4561
4652
  * @example
4562
4653
  * ```ts
4563
4654
  * import { parseAstmRecords, serializeFramedAstm, parseFramedAstm } from "@cosyte/astm";