@cosyte/astm 0.0.15 → 0.0.17

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/CHANGELOG.md CHANGED
@@ -9,8 +9,226 @@ this file is maintained by hand (Changesets handles the version bump and publish
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Added
13
+
14
+ - **Two parse-path warning codes for two readings the parser could not defend, both landing on the
15
+ profile safety gate** (`ASTM-FRAME-RESIDUALS`, defects 4 and 11). Both are additive: no existing
16
+ warning was removed or renamed, no split changed, and every extracted value is byte-identical to
17
+ what it was. What changed is that each condition is now reported by a code **no profile may
18
+ tolerate**, so `{ strict: true }` refuses a stream carrying it whatever profile is in force.
19
+ - `ASTM_RECORD_DELIMITER_ROLE_COLLISION`: an `H` record declared one character in two of the
20
+ repeat, component and escape roles, so the boundary between those two roles is not in the bytes.
21
+ A declaration whose **field** separator is one of the other three was already refused (the
22
+ `ASTM_RECORD_UNDECLARED_DELIMITERS` fatal on the first header, `ASTM_RECORD_UNREADABLE_REDECLARATION`
23
+ on a later one), so this covers the three unordered pairs among the rest: repeat/component,
24
+ repeat/escape, component/escape. The declaration is still honored and no record is dropped.
25
+ Measured: under `H|^^&` the field `A^B^C^D` reads back as four repeats of one component each, so
26
+ a two-repeats-of-two-components reading is unrecoverable; under `H|\&&` the same character
27
+ splits (`A&B` is two components) or opens an atom (`A&F&B` is the single component `A|B`)
28
+ depending only on what follows it. Emit has always refused these sets
29
+ (`ASTM_EMIT_INVALID_DELIMITERS`), which is how the gap became visible from the outside.
30
+ - `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`: an escape sequence whose body was **not** a
31
+ recognized mnemonic held a character that is one of the three splitting delimiters in force, so
32
+ the atom rule kept it out of the split and the record was read with one fewer division than its
33
+ bytes carry. Measured on the canonical set: `R|1|^^^687|28.6&|&U/L||||F` reads a value of
34
+ `28.6&|&U/L`, with **no units** and status `unspecified` rather than `final`. It fires alongside
35
+ `ASTM_UNKNOWN_ESCAPE_SEQUENCE`, which stays and stays tolerable, because it remains true and
36
+ benign of every unrecognized body that is not a delimiter in force.
37
+
38
+ **The measure that discriminates is strict-accepted-under-a-gate-legal-profile, and "silent" was
39
+ never available here.** Both conditions always raised a tolerable code already
40
+ (`ASTM_NONSTANDARD_DELIMITERS` for the first, since any colliding set is by definition
41
+ non-canonical; `ASTM_UNKNOWN_ESCAPE_SEQUENCE` for the second), so `warnings: []` was structurally
42
+ unreachable for either class and "it is never silent" was true and told you nothing. What it hid is
43
+ that a profile tolerating the weaker code left a strict parse accepting the reading.
44
+
45
+ Measured against the corpus constants committed with the tests, not against a sweep whose
46
+ parameters are not in the tree. Over `DELIMITER_ALPHABET` and `sweepStream` in
47
+ `test/records/delimiter-role-collision.test.ts` (the twelve characters `|` `\` `^` `&` `~` `:` `#`
48
+ `*` `!` `@` `$` `%`, a header plus a terminator), 15,972 of the 20,736 four-role tuples resolve;
49
+ 4,092 of those name one character in two roles and 11,880 do not. Under a gate-legal profile
50
+ tolerating `ASTM_NONSTANDARD_DELIMITERS` and `{ strict: true }`, **4,092 of the 4,092 were accepted
51
+ before and 0 are now**, while all 11,880 non-colliding sets are accepted exactly as before. Over
52
+ `BODY_ALPHABET` and `bodyStream` in `test/records/swallowed-delimiter.test.ts` (the same twelve
53
+ characters as escape bodies in one spec-clean result record, read identically in all twelve cases),
54
+ **3 report the new code, enumerated as `|`, `\` and `^`**, the field, repeat and component roles of
55
+ the canonical set; the escape role `&` is deliberately not among them, because nothing splits on
56
+ it, and the other eight are not delimiters at all. Under `referenceCorpus` and `{ strict: true }`,
57
+ 12 of 12 were accepted before and 9 are now.
58
+
59
+ **Two things these codes do not do, stated so they are not read into them.** Neither repairs a
60
+ reading: the atom rule is unchanged, because it is what keeps `&F&` one token under a set that
61
+ names `F` as a delimiter, and a colliding declaration is still honored rather than refused. And the
62
+ second does not survive a re-emit: the serializer rewrites the preserved sequence into recognized
63
+ mnemonics, and that stream says the same value unambiguously, so a second-generation read is silent
64
+ and is correct about its own bytes. The first read of the wire bytes is where the condition exists
65
+ to be caught, and that is where the refusal lands.
66
+
67
+ This is a **narrowing on a published package**, on the strict path only: a lenient parse of the
68
+ same stream returns the same records with one more warning on them. New public surface:
69
+ `hasCollidingRoles`, the `delimiterRoleCollision` and `delimiterSwallowedByEscape` warning
70
+ factories, the `SwallowedDelimiterSink` type, and a trailing optional sink parameter on
71
+ `decodeEscapes`, `tokenizeRecord` and `tokenizeHeader`.
72
+
73
+ - **A third parse-path warning code, for a boundary the reading may have GAINED**
74
+ (`ASTM-FRAME-RESIDUALS`, defect 15, the mirror of defect 11). `ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT`
75
+ fires where the escape character that **closed** an unrecognized escape sequence could instead have
76
+ **opened** one whose body is the delimiter that split, so the same bytes carry two alignments that
77
+ disagree by exactly one field, repeat or component boundary. Measured on the canonical set:
78
+ `R|1|^^^687|28.6&Z&|&U/L||||F` reads a value of `28.6&Z&` and units of `&U/L` under the leftmost
79
+ alignment, and reads as one unsplit field carrying both under the other. It is not tolerable, so
80
+ `{ strict: true }` refuses such a record whatever profile is in force.
81
+
82
+ **The direction is why it needed a code rather than being left to its sibling.** That one reports a
83
+ boundary the reading lost; this one reports a boundary the reading may have gained, which is the
84
+ more dangerous half, because a gained boundary hands a consumer a value and a units string the
85
+ sender's bytes do not unambiguously carry. Both codes the condition raised before
86
+ (`ASTM_UNKNOWN_ESCAPE_SEQUENCE` and `ASTM_UNPAIRED_ESCAPE_CHARACTER`) are on the profile
87
+ allow-list, so a gate-legal profile naming them left a strict parse accepting the altered reading.
88
+ Both stay on the list and stay true of the cases that cost nothing.
89
+
90
+ **Additive, and the split is unchanged.** The leftmost alignment is still the one taken and every
91
+ decoded byte is identical. Picking the other alignment was considered and rejected: it is a
92
+ different guess with no more evidence behind it, and it would move values on a package that is
93
+ already published. Two exclusions are deliberate, and the first is **wider than the argument for
94
+ it**, which is stated rather than left to be found. A **recognized** mnemonic before the delimiter
95
+ raises nothing, because the reading taken interprets a construct (`&F&` is the sender escaping a
96
+ field separator, which is what the mechanism is for) while the competitor's body is a delimiter
97
+ character this codec usually cannot interpret, so its own vocabulary prefers the reading taken.
98
+ That is not the same as the reading taken being conformant, and two cases fall in the gap, both
99
+ measured and both left open as their own defect rather than closed by widening the test:
100
+ `28.6&F&|&U/L` reads a value of `28.6|` and units of `&U/L` under a bare escape character that this
101
+ package reports only as the tolerable `ASTM_UNPAIRED_ESCAPE_CHARACTER`, and a declared set naming a
102
+ mnemonic letter as a splitting delimiter leaves both alignments interpreting exactly one construct
103
+ with neither preferred. The criterion that would cover them is a different one (counting what each
104
+ alignment interprets), and swapping criteria moves which streams a published package refuses. The
105
+ second exclusion is a delimiter with no escape character two positions past it, which raises
106
+ nothing because there is no competing alignment. What does fire is a subset of the records that
107
+ already raise `ASTM_UNKNOWN_ESCAPE_SEQUENCE`.
108
+
109
+ **Measured against the corpus constants committed with the test**, on the same
110
+ strict-accepted-under-a-gate-legal-profile tier, because "silent" is structurally unreachable here
111
+ too. Over `ALIGNMENT_ALPHABET` and `neutralStream` in
112
+ `test/records/escape-alignment-ambiguity.test.ts` (twelve characters: the four mnemonics `F` `S`
113
+ `R` `E`, the three splitting roles `|` `\` `^`, the escape role `&`, and `~` `:` `#` `*`, swept in
114
+ both positions of the pair that decides this, carried on a comment record so no result-semantics
115
+ warning masks the count), of 144 tuples **24 raise the new code**, being the eight unrecognized
116
+ bodies against the three splitting roles. Under a profile built from the whole tolerable
117
+ allow-list, which is the widest a gate-legal profile can be, and `{ strict: true }`, **108 of 144
118
+ were accepted before and 93 are now**. The fire count and the acceptance delta are **not the same
119
+ number**: of the 24 that fire, 9 were already refused by
120
+ `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`, so 15 is what moved.
121
+
122
+ **It does not repair the reading and it does not survive a re-emit**, the same residue its sibling
123
+ disclosed: the serializer rewrites the preserved characters into recognized mnemonics, and that
124
+ stream carries the alignment that was taken with no competitor left in it, so a second-generation
125
+ read is silent and is correct about its own bytes. The first read of the wire bytes is where the
126
+ ambiguity exists to be caught.
127
+
128
+ This is a **narrowing on a published package**, on the strict path only: a lenient parse of the
129
+ same stream returns the same records with one more warning on them. New public surface:
130
+ `ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT`, the `ambiguousEscapeAlignment` warning factory, the
131
+ `AmbiguousAlignmentSink` type, and a trailing optional sink parameter on `splitEscapeAware`,
132
+ `tokenizeRecord` and `tokenizeHeader`.
133
+
134
+ - **A fourth parse-path warning code, for the first of these that moves a MODELED CLINICAL SLOT
135
+ rather than a boundary** (`ASTM-FRAME-RESIDUALS`, defect 17(a)).
136
+ `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS` fires where two escape alignments disagree about a **field**
137
+ boundary, the reading taken keeps it, and the escape character that reading resumes on heads **no
138
+ escape sequence at all**, which is exactly the character the competing alignment uses to close its
139
+ own sequence. A gained field boundary shifts every later field one place. It is not tolerable, so
140
+ `{ strict: true }` refuses such a record whatever profile is in force.
141
+
142
+ **What the shift costs, measured on the canonical set.** `R|1|^^^687|28.6&F&|&U/L||||F` reads
143
+ **nine** fields under the alignment taken and **eight** under the other, so the sender's trailing
144
+ `F` lands in field 9, the result status, under the first and in **no field at all** under the
145
+ second. The parse hands back units of `&U/L` and a status of **`final`**, and both are consequences
146
+ of the alignment rather than values the sender placed in those slots. A downstream system reading
147
+ `final` acts on a result these bytes do not say was finalised. The only warning on that stream
148
+ before this code was the tolerable `ASTM_UNPAIRED_ESCAPE_CHARACTER`, so the widest gate-legal
149
+ profile plus `{ strict: true }` accepted it. **The predecessor entry above lists that `final` among
150
+ the values read without saying it is fabricated; this is that correction.**
151
+
152
+ **It is a second question about the same position, not a widening of the code above.** That one
153
+ asks whether this codec's own vocabulary prefers the alignment taken _at_ the contested position,
154
+ and is silent where the earlier body is a recognized mnemonic. This one asks what the alignment
155
+ taken makes of the bytes _after_ the boundary, and does not consult the earlier body at all. The
156
+ earlier code's exclusions are untouched, and the two fire alongside each other where both apply.
157
+
158
+ **Additive, and the split is unchanged.** Every decoded byte is identical, including the status,
159
+ which is still read as `final`: this reports the shift, it does not repair it. Picking the other
160
+ alignment was rejected for the same reason as before, and withholding the shifted slots was
161
+ weighed and deferred, because declining to model a slot changes an extracted value on a published
162
+ package and wants its own measurement of the population it moves.
163
+
164
+ **Two bounds, both deliberate, and neither is a claim that nothing was lost outside it.** It is
165
+ wired to the **field** separator only, because a gained repeat or component boundary divides one
166
+ field and so moves no **field-indexed** slot: the units and the status stay put. **That is a choice
167
+ and not a consequence, and stating it as a consequence would be false.** Components are modeled
168
+ _inside_ a field, so a gained **component** boundary does move a modeled slot:
169
+ `R|1|&F&^&GLU^L^687|28.6|U/L||||F` reads a Universal Test ID coding scheme of `L` and local code of
170
+ `687` under the alignment taken, and `687` as the **coding scheme** under the other, while
171
+ `DOE&F&^&JANE^A` reads `&JANE` as a first name under one and `A` under the other. Both raise only
172
+ the tolerable `ASTM_UNPAIRED_ESCAPE_CHARACTER` and are strict-accepted under the widest gate-legal
173
+ profile, and both are **`PRE-EXISTING`** (byte-identical on the base of this slice), so they are
174
+ disclosed here as a separate open condition rather than covered: wiring this sink to another
175
+ delimiter role is a different criterion and wants its own population measurement. On the repeat role
176
+ what is lost is the value, also not reported here. And the tail is weighed **one construct deep**: where
177
+ the escape character past the boundary heads a **recognized** sequence, the alignment taken
178
+ interprets it while the competing one would leave it bare, so the bytes prefer the alignment taken
179
+ (under a set naming the field separator `F`, `28.6&F&F&F&U/L` is that separator escaped, written and
180
+ escaped again, entirely well formed, and refusing it is the over-refusal that sank a criterion
181
+ measured for this same family); and where it heads an **unrecognized** sequence, the alignment taken
182
+ still consumes it while the competing one would leave two escape characters bare, so the preference
183
+ is stronger again. That second case still shifts the fields and is silent here. It is recorded as a
184
+ residue, measured, not overlooked.
185
+
186
+ **Measured against the corpus constants committed with the test**, on the same
187
+ strict-accepted-under-a-gate-legal-profile tier. Over `DECLARATION_ALPHABET`, `SPLITTING_ROLES`,
188
+ `BODY_ALPHABET` and `TAIL_SUFFIXES` in `test/records/alignment-shifted-fields.test.ts`, **864
189
+ tuples**: the code fires on **96**, moves **32** from accepted to refused under a profile built from
190
+ the whole tolerable allow-list, and moves **0** back. It fires on **none** of the 96 escape-clean
191
+ tuples, and cannot: firing requires an escape character heading no sequence, which this package
192
+ already reports as a deviation of its own. The criterion measured and rejected on this same corpus
193
+ would have refused 48 of them. The 32 that move are exactly the population the code above left
194
+ silent through its recognized-body exclusion.
195
+
196
+ **It does not survive a re-emit**, the same residue its siblings disclosed: the serializer rewrites
197
+ the preserved characters into recognized mnemonics, so a second-generation read is silent and is
198
+ correct about its own bytes. The first read of the wire bytes is where this exists to be caught, and
199
+ a clean re-read is not evidence.
200
+
201
+ This is a **narrowing on a published package**, on the strict path only: a lenient parse of the same
202
+ stream returns the same records, with the same values, and one more warning on them. New public
203
+ surface: `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`, the `alignmentShiftedFields` warning factory, the
204
+ `ShiftedFieldsSink` type, and a trailing optional sink parameter on `splitEscapeAware`,
205
+ `tokenizeRecord` and `tokenizeHeader`.
206
+
12
207
  ### Fixed
13
208
 
209
+ - **A header that is not short no longer reports that it is too short** (`ASTM-FRAME-RESIDUALS`,
210
+ defect 16). `readDelimiters` had four ways to fail and the first header's fatal described all of
211
+ them with one sentence. Three were accurate; the fourth was not. `H||^&` is a full-length header
212
+ whose delimiter-definition field is empty because its own field separator ends the definition where
213
+ it begins, and it raised `ASTM_RECORD_UNDECLARED_DELIMITERS` reading "Header record is too short to
214
+ declare the four delimiters", which sent a reader counting characters instead of looking at the
215
+ declaration. `readDelimiterDeclaration` is `readDelimiters` with the reason kept, and the fatal now
216
+ carries the message for the reason it hit.
217
+
218
+ **The fatal code is unchanged and no stream's disposition moved.** A consumer switching on
219
+ `ASTM_RECORD_UNDECLARED_DELIMITERS` sees exactly what it saw before; only the human-readable
220
+ sentence changed. Splitting the code in two was considered and rejected: it would move a published
221
+ code for a stream whose outcome is already right, which is a breaking change bought for a sentence.
222
+
223
+ The reader's field-separator-reused branch is **unreachable** and stays. Such a separator ends the
224
+ delimiter definition where it appears, so the under-three-characters rule answers first, every
225
+ time, and that is now measured rather than argued: over `FIELD_SEPARATOR_ALPHABET` and
226
+ `DEFINITION_POSITIONS` in `test/common/delimiter-declaration-faults.test.ts`, all 36 collision-shaped
227
+ headers classify as a truncated definition and none as a reuse. Deleting the branch would leave the
228
+ invariant unstated and a change to how the definition field is bounded silent. New public surface:
229
+ `readDelimiterDeclaration`, the `DelimiterDeclarationFault` type, and a `fault` on the failure arm
230
+ of the previously unused `DelimiterReadResult`.
231
+
14
232
  - **The escape encoder protected the escape character it introduced but not the `E`/`F`/`S`/`R`
15
233
  mnemonics, so a delimiter set naming one of those letters in another role altered the values it
16
234
  emitted** (`ASTM-FRAME-RESIDUALS` follow-up, defect 12). `PRE-EXISTING`, byte-identical on
package/README.md CHANGED
@@ -177,15 +177,93 @@ package's serializer emits, so a stream it produced never trips the code. The co
177
177
  so a vendor profile can expect it on a feed that sends bare ampersands and still parse
178
178
  `{ strict: true }`.
179
179
 
180
- **One escape shape still costs a field boundary, and it is a different code.** A real three-character
181
- sequence is opaque by design, which is what keeps `&F&` one token under a set that names `F` as a
182
- delimiter. So where the body is itself a delimiter (`&|&` under the canonical set) that delimiter
183
- does not split, and every field after it shifts: `R|1|^^^687|28.6&|&U/L||||F` reads a value of
184
- `28.6&|&U/L` with no units and status `unspecified`. It is never silent, but the only report is
185
- `ASTM_UNKNOWN_ESCAPE_SEQUENCE`, which is **tolerable**, so a profile expecting that code (including
186
- the shipped `referenceCorpus`) will let a `{ strict: true }` parse accept it. Narrowing the atom to
187
- close this would break the guarantee the atom exists for, so it is written down rather than papered
188
- over.
180
+ **One escape shape still costs a field boundary, and it has a code of its own.** A real
181
+ three-character sequence is opaque by design, which is what keeps `&F&` one token under a set that
182
+ names `F` as a delimiter. So where the body is an **unrecognized** character that is itself a
183
+ delimiter in force (`&|&` under the canonical set)
184
+ that delimiter does not split, and every field after it shifts: `R|1|^^^687|28.6&|&U/L||||F` reads a
185
+ value of `28.6&|&U/L` with no units and status `unspecified`. That reading is unchanged, and
186
+ narrowing the atom to change it would break the guarantee the atom exists for. What such a record now
187
+ raises, alongside the tolerable `ASTM_UNKNOWN_ESCAPE_SEQUENCE`, is
188
+ `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`, which **no profile may tolerate**, so a
189
+ `{ strict: true }` parse refuses it even under the shipped `referenceCorpus`. The narrower code fires
190
+ only where the unrecognized body is one of the three splitting roles in force. Two exclusions are
191
+ deliberate: the escape role, because nothing splits on it, and every **recognized** mnemonic, because
192
+ `&F&` under a set naming `F` as the repeat delimiter is the sender escaping the field separator on
193
+ purpose, and reporting that would report the escape mechanism working as a defect.
194
+
195
+ Read it as a report, not a repair. It also does not survive a re-emit: the serializer rewrites the
196
+ preserved sequence into recognized mnemonics, and that stream says the same value unambiguously, so a
197
+ second-generation read is silent and is right about its own bytes. The first read of the wire bytes
198
+ is where the condition exists to be caught.
199
+
200
+ **The mirror of it costs a boundary in the other direction, and it also has a code of its own.**
201
+ Sequences are matched greedily and leftmost, so the escape character that closes one cannot also open
202
+ the next. Where it could have, the same bytes carry two alignments that disagree by one boundary:
203
+ `R|1|^^^687|28.6&Z&|&U/L||||F` reads a value of `28.6&Z&` and units of `&U/L` under the alignment
204
+ taken, and reads as a single unsplit field carrying both under the other. Every byte is preserved and
205
+ the leftmost reading is kept (picking the other one would be a different guess with no more evidence
206
+ behind it), but the boundary it hands you is a choice, so it raises
207
+ `ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT`, which **no profile may tolerate**. Both codes the condition
208
+ raised before (`ASTM_UNKNOWN_ESCAPE_SEQUENCE` and `ASTM_UNPAIRED_ESCAPE_CHARACTER`) are tolerable, so
209
+ a strict parse under a profile naming them used to accept it. Two exclusions again. A **recognized**
210
+ mnemonic before the delimiter is silent, because the reading taken interprets a construct (`&F&` is
211
+ the sender escaping a separator, which is what the mechanism is for) while the competitor's body is a
212
+ delimiter character the codec usually cannot interpret, so its own vocabulary prefers the reading
213
+ taken. That is **not** the same as the reading taken being conformant, and the exclusion is wider
214
+ than that argument: `28.6&F&|&U/L` reads a value of `28.6|` and units of `&U/L` while raising only
215
+ the tolerable `ASTM_UNPAIRED_ESCAPE_CHARACTER`, and a declared set naming a mnemonic letter as a
216
+ splitting delimiter makes both alignments interpret one construct each with neither preferred. The
217
+ first of those is covered by a second code, below; the second is measured and recorded as an open
218
+ residue. The other exclusion is a delimiter with no escape character two positions past it, which is
219
+ no competing alignment at all. What does fire is a subset of what already raises
220
+ `ASTM_UNKNOWN_ESCAPE_SEQUENCE`. It does not survive a re-emit either, for the same reason as its
221
+ mirror: catch it on the first read.
222
+
223
+ **And where that gained boundary is a FIELD boundary, it moves a result's status, which is a
224
+ different question and so a different code.** The two alignments resume one character apart, so they
225
+ disagree about every byte after the boundary, not only about it. Where the escape character the
226
+ reading taken resumes on heads no sequence at all, that reading bought the boundary with a byte it
227
+ cannot read while the competing alignment is the one that can use it. On the field separator that
228
+ matters clinically: `R|1|^^^687|28.6&F&|&U/L||||F` reads **nine** fields under the alignment taken
229
+ and **eight** under the other, so the sender's trailing `F` lands in field 9, the result status,
230
+ under the first and in no field at all under the second. The parse hands back units of `&U/L` and a
231
+ status of **`final`**, and both are consequences of the alignment rather than values the sender put
232
+ in those slots. That raises `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`, which **no profile may
233
+ tolerate**; before it existed the only warning on that stream was the tolerable
234
+ `ASTM_UNPAIRED_ESCAPE_CHARACTER`, so a strict parse under a legal profile accepted it. The reading is
235
+ unchanged: it reports the shift rather than repairing it. It is wired to the **field** separator
236
+ only, because a gained repeat or component boundary divides one field and so moves no field-indexed
237
+ slot. **That bound is a choice, not a consequence**: components are modeled inside a field, so a
238
+ gained **component** boundary does move a modeled slot, and `R|1|&F&^&GLU^L^687|28.6|U/L||||F` reads
239
+ `687` as a local code under one alignment and as the coding scheme under the other, with only a
240
+ tolerable code raised. That is a separate open condition, not something this code covers. It is also
241
+ silent where the trailing escape character heads a sequence of its own, recognized or not; that case
242
+ still shifts the fields and is the recorded residue. It does not survive a re-emit either: catch it
243
+ on the first read.
244
+
245
+ ### A header that names one character in two delimiter roles
246
+
247
+ ASTM messages are self-describing: the `H` record declares the four delimiters and a conformant
248
+ reader follows them. A declaration whose **field** separator is also one of the other three is
249
+ refused outright, because the four roles would be indistinguishable. A declaration where two of the
250
+ _remaining_ three agree is still read, because the stream is readable and refusing it would drop
251
+ records the sender did send, but the boundary between those two roles is no longer in the bytes.
252
+
253
+ Under `H|^^&`, where the repeat and component roles are both `^`, a field a canonical sender would
254
+ have written as two repeats of two components reads back as four repeats of one component each.
255
+ Under `H|\&&`, where the component and escape roles are both `&`, the same character splits
256
+ (`A&B` is two components) or opens an escape atom (`A&F&B` is the single component `A|B`) depending
257
+ only on what follows it.
258
+
259
+ Such a header now raises `ASTM_RECORD_DELIMITER_ROLE_COLLISION`, at the header that put the set into
260
+ force, once rather than once per colliding pair. A later header restating the set already in force
261
+ warns nothing, on the same rule that governs the other delimiter warnings. The code is **not** tolerable. That matters
262
+ because every such set is by definition non-canonical, so the only warning it used to raise was
263
+ `ASTM_NONSTANDARD_DELIMITERS`, which is tolerable: a profile expecting an ordinary vendor set left a
264
+ `{ strict: true }` parse accepting a declaration whose own field tree cannot be recovered. Emit has
265
+ always refused these sets (`ASTM_EMIT_INVALID_DELIMITERS`), which is how the gap first showed: a
266
+ message that parsed clean threw when it was serialized back against its own declared delimiters.
189
267
 
190
268
  ### Several messages in one stream
191
269
 
@@ -251,9 +329,17 @@ outside it:
251
329
  and the damage then varies. A mis-split component can cost a test identity while the value and
252
330
  units survive. The escape role's worst case has **narrowed, not gone**: a bare escape character no
253
331
  longer merges the rest of the record (it reads as a literal and raises
254
- `ASTM_UNPAIRED_ESCAPE_CHARACTER`), but an `&X&` sequence whose body is a delimiter is an opaque
332
+ `ASTM_UNPAIRED_ESCAPE_CHARACTER`), but an `&X&` sequence whose body is an unrecognized character
333
+ that is itself a delimiter in force is an opaque
255
334
  atom, so that delimiter does not split and the value, the units and the status can still go
256
- together. That one is reported only by the tolerable `ASTM_UNKNOWN_ESCAPE_SEQUENCE`.
335
+ together. That one raises `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`, which is not tolerable,
336
+ alongside the tolerable `ASTM_UNKNOWN_ESCAPE_SEQUENCE`. The split itself is unchanged. Its mirror,
337
+ where the leftmost alignment lets a delimiter split that a competing alignment would have held,
338
+ gains a boundary instead of losing one and raises `ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT`, also
339
+ not tolerable. Where that gained boundary is a **field** boundary and the reading taken resumes on
340
+ an escape character heading no sequence, every later field shifts one place and a result's units
341
+ and status are read out of slots the other alignment does not put them in: that raises
342
+ `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`, not tolerable either.
257
343
 
258
344
  All are accepted limits, for two different reasons: widening the field-separator check would mean
259
345
  deciding which set a record ought to have had, which is the same guess the parser declines to make