Artifact b83de8b00c065dd42bb04a6ea8765ba92b8fea77562edd943fd259d15adb90df:
- File docs/rfcs/erratum/artifact-format-00-s4.2-empty-trailing-token.md — part of check-in [44685f33b1] at 2026-10-12 18:45:05 on branch trunk — Fold the 23 errata into draft-fossil-{artifact-format,repo-state,sync-protocol}-01 (user: dmestas, size: 8798) [annotate] [blame] [check-ins using]
Technical erratum — draft-fossil-artifact-format-00 §4.2
| Document | draft-fossil-artifact-format-00 |
| Section | 4.2 — Line and Token Grammar (consequences in 4.7.4 C Card, 4.7.8 J Card, 4.7.16 T Card) |
| Type | Technical (the token grammar is stricter than the reference implementation) |
| Status | Applied in draft-fossil-artifact-format-01 (2026-10-12) |
| Reported | 2026-08-16 |
| Submitter | Dan Mestas |
| Found via | Indexing --compatible fossil→zit imports of sqlite.org/src and core.tcl-lang.org/tcl; every ticket-change artifact carrying an empty field was rejected |
1. Summary
§4.2 gives token = 1*tchar, making an empty token invalid everywhere.
§4.7.17 then carves out a single exception for the U card, noting that its
grammar "takes precedence over the general requirement that a card argument
be a non-empty token."
Fossil's parser applies no such general requirement. next_token() returns a
non-NULL empty string for a trailing space on any card, and the C, J and T
cards accept it as a present, empty value. Real repositories rely on this:
Fossil writes an empty value as a bare trailing space. A reader that
implements §4.2 literally rejects those artifacts.
The U card exception is therefore not an exception — it is one instance of a
general rule the document does not state. The rule is POSITIONAL, not
per-card: it is not confined to grammars written [ SP value ]. The C card's
value is mandatory, yet C LF-with-trailing-space is a present, empty
comment that Fossil accepts, and it is the most common instance in practice.
2. The text in error
Page 11 (§4.2):
card-args = token *( SP token ) token = 1*tchar
and, page 22 (§4.7.8) and page 25 (§4.7.16), the [ SP value ] production in
j-card and t-card, which inherits token's non-empty requirement and so
cannot express a present-but-empty value.
and, page 20 (§4.7.4):
Exactly one required token, escape-decoded; single-occurrence; a C card with no token MUST be rejected.
This last conflates two distinct inputs. C LF (no space) genuinely has no
token and Fossil rejects it. C SP LF has one token, which is empty, and
Fossil accepts it as an empty comment.
3. What Fossil implements
manifest.c:
manifest.c:383-395 next_token(): returns 0 only when already at EOL;
otherwise n = strcspn(z, " \n") — which may be 0 —
and the token is returned as a NUL-terminated string.
manifest.c:965-966 T card: zValue = next_token(&x,0); if( zValue ) defossilize(zValue);
manifest.c:784-786 J card: zValue = next_token(&x,0); if( zValue==0 ) zValue = "";
manifest.c:629-630 C card: p->zComment = next_token(&x,0);
if( p->zComment==0 ) SYNTAX("missing comment text on C-card");
manifest.c:399-412 next_card(): only z[1]==' ' or z[1]=='\n' continues; the
'\n' branch sets atEol, which is what makes a bare "C"
yield NULL from the following next_token().
For the line T +date <hash> LF, the third next_token call sees strcspn
return 0 at the LF, sets atEol, and returns a pointer to an empty string —
non-NULL. The T card stores that as the tag's value. Only a card line with no
trailing space at all yields NULL and hence an absent value.
This distinction is exactly the one §4.7.16 already draws ("An absent value yields null (unlike the J card's empty-string default)"): under Fossil,
T +date <hash>— value absent, null.T +date <hash>— value present, empty string.
Both forms occur in the wild. For the J card the two collapse, since an absent J value already defaults to the empty string.
4. Corrected text
In §4.2, after the grammar block, add:
The final token of a card MAY be empty, on any card letter. When a card line's last octet before LF is SP, the final token is PRESENT and its decoded form is the empty string — distinct from a card that omits the token entirely, which is the argument-less form
card-letter LF. Whether an empty final token is legal for a given card is then decided by that card's own arity and validation in Section 4.7, not by this grammar. Empty tokens remain invalid in every other position: a card line containing two consecutive SP octets is malformed.
and amend the grammar so only the non-final tokens are bound by token:
card-args = *( token SP ) value token = 1*tchar value = *tchar
Note this formulation covers the C card, whose value is mandatory rather than
optional, without a second rule: C SP LF yields card-args of one empty
value, which §4.7.4 then accepts as an empty comment.
In §4.7.4, replace the rejection clause so it distinguishes an absent token from an empty one:
Exactly one required token, escape-decoded; single-occurrence. A C card with no token — a bare
Cimmediately followed by LF — MUST be rejected. A C card whose single token is empty (C, SP, LF) is a present comment whose decoded value is the empty string, and MUST be accepted.
In §4.7.17, the sentence beginning "This card's grammar, with its optional and possibly-empty login token, governs the U card and takes precedence over the general requirement…" should be reduced to a cross-reference, since after the §4.2 correction the U card no longer takes precedence over anything:
The U card's possibly-empty login token is the general trailing-value rule of Section 4.2; the substitution of
anonymousis what is specific to this card.
5. Observable consequence of the uncorrected text
A conforming reader rejects the whole artifact on J name LF-with-trailing-space
and on T +tag <hash> LF-with-trailing-space. Because these are ticket-change
and tag artifacts, the loss is not partial degradation of one record — the
artifact fails to parse at all and, in any implementation that falls back to
treating unparsable artifacts as opaque content, disappears from derived
state without an error surfacing to the user.
Measured against sqlite.org/src (131,558 artifacts) and core.tcl-lang.org/tcl (254,469 artifacts), with a reader implementing §4.2 literally:
- sqlite carries 2,100 ticket-change artifacts and zero were indexed; after the correction, all 794 distinct tickets appear.
- 1,009 artifacts across the two corpora carry an empty C card. These are the largest single class, and rejecting them drops whole check-ins — not merely their comment text.
The C-card case is the one most likely to be missed by an implementer reading §4.7.4 alone, because that section's rejection clause reads as though it already settles the question.
6. Scope note
This erratum concerns only the trailing position. §4.2.1 (Exact Arity) is
unaffected and its maximum-token counts continue to apply: a trailing empty
token occupies the card's final optional slot and does not add one. An
interior empty token — two consecutive spaces — remains a rejection, which
matches next_token, where an interior strcspn of 0 produces an empty
token in a non-final position that each card's own validation then refuses.
Implementations should check whether their tokenizer rejects empty tokens before the per-card grammar is consulted; that ordering is what turns a legal artifact into a parse failure.
7. Conformance of this implementation
zit was not exempt, contrary to the first reading of the report. Its tokenizer distinguishes absent from empty correctly, but two card parsers then rejected the empty form before their own card grammar was consulted — the exact ordering flagged at the end of Section 6:
| card | reference implementation | zit before the fix | after |
|---|---|---|---|
T +x <hash> SP LF |
value present, empty | present, empty | unchanged |
U SP LF / bare U |
anonymous |
anonymous |
unchanged |
C SP LF |
present, empty comment | rejected | present, empty |
J field SP LF |
present, empty value | rejected | present, empty |
Both rejections were load-bearing in tests that asserted them, so the defect was
pinned rather than latent: §4.7.4-r1 asserted C SP LF was rejected under a
title that said "a C card with no token", conflating the two inputs in exactly
the way Section 2 describes. Those tests now assert the corrected behaviour, and
a third covers the writer: an empty comment survives create as C SP LF, which
matters because import-fossil --submanifest re-mints every manifest it carries.
The consequence for zit would have been the same class of loss measured against quarry, and for the same reason: a check-in whose manifest fails to parse leaves derived state without surfacing an error.