Element boundaries are not observed by ==, hash, or Codable, so two values that compare equal and hash identically can render a different number of container rows — and a Codable round trip or an identity transform silently changes that layout.
This is pre-existing on main, not introduced by #1. It was found during that PR's sixth review round and deliberately deferred there, because fixing it requires an encoding-format decision that does not belong in a storage refactor. Filing it now so the deferral has a real tracking record rather than one sentence in a design document.
Reproduction
Verified by building the same driver against main and against #1's head — both produce identical output:
var oneElement = SemanticString()
oneElement.append(Group([SemanticString(Standard("alpha")), SemanticString(Standard("beta"))]))
var twoElements = SemanticString()
twoElements.append(Standard("alpha"))
twoElements.append(Standard("beta"))
oneElement == twoElements // true
oneElement.hashValue == twoElements.hashValue // true
// ...yet they are different documents:
SemanticString(MemberList(level: 1, content: oneElement)).string // 1 row
SemanticString(MemberList(level: 1, content: twoElements)).string // 2 rows
A round trip through the type's own Codable conformance changes the layout while still comparing equal to its source:
let decoded = try JSONDecoder().decode(
SemanticString.self,
from: try JSONEncoder().encode(oneElement)
)
decoded == oneElement // true
// but renders 2 rows, not 1
So does an identity transform:
oneElement.replacing { $0 } // equal, renders 2 rows
replacing, filter, prefix, trimmingWhitespace and the other transformations all funnel through init(components: [AtomicComponent]), which resets boundaries to 1:1.
Why it matters
MemberList / Rows / BlockList treat one element as one row. A value that survives a serialization boundary — persisted to disk, sent over XPC, or merely passed through an identity transform — comes back rendering a different document, with no measure a caller can inspect to detect the change.
Fixing it means choosing
- Encode boundaries. Round trips become faithful, at the cost of a format change and a compatibility story for existing payloads.
- Include boundaries in
== / hash. Equality then matches rendering, but two values with identical components stop comparing equal, which is its own surprise.
- Document the current behavior as intended and state that element granularity is not preserved across serialization — the cheapest option, and it at least stops the guarantee being implied.
Option 3 is arguably already half-true: granularity is not a property of the value alone in any case. Appending the same SemanticString through a generic parameter versus directly selects different overloads and yields different granularity (see docs/SeventhReviewFixes.md).
References
docs/SixthReviewFixes.md — the original deferral
docs/SettledFindings.md entry S6 — the standing disposition
Element boundaries are not observed by
==,hash, orCodable, so two values that compare equal and hash identically can render a different number of container rows — and aCodableround trip or an identity transform silently changes that layout.This is pre-existing on
main, not introduced by #1. It was found during that PR's sixth review round and deliberately deferred there, because fixing it requires an encoding-format decision that does not belong in a storage refactor. Filing it now so the deferral has a real tracking record rather than one sentence in a design document.Reproduction
Verified by building the same driver against
mainand against #1's head — both produce identical output:A round trip through the type's own
Codableconformance changes the layout while still comparing equal to its source:So does an identity transform:
replacing,filter,prefix,trimmingWhitespaceand the other transformations all funnel throughinit(components: [AtomicComponent]), which resets boundaries to 1:1.Why it matters
MemberList/Rows/BlockListtreat one element as one row. A value that survives a serialization boundary — persisted to disk, sent over XPC, or merely passed through an identity transform — comes back rendering a different document, with no measure a caller can inspect to detect the change.Fixing it means choosing
==/hash. Equality then matches rendering, but two values with identical components stop comparing equal, which is its own surprise.Option 3 is arguably already half-true: granularity is not a property of the value alone in any case. Appending the same
SemanticStringthrough a generic parameter versus directly selects different overloads and yields different granularity (seedocs/SeventhReviewFixes.md).References
docs/SixthReviewFixes.md— the original deferraldocs/SettledFindings.mdentry S6 — the standing disposition