Adopt read-side coercion for schema-owned fields

Design-review resolutions: schema-owned display fields coerce where a
sensible reading exists (wrong-type scalars read as source text, width
accepts exact-integer strings/doubles) and default where none does;
null reads as missing; duplicate keys last-one-wins and inline-comment
re-splicing recorded in the design (engine change follows). Strict
schema/order fail-fast unchanged. 85 tests green.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 15:47:33 -04:00
parent a6f4960492
commit 8e7c565e07
4 changed files with 107 additions and 22 deletions
+2 -2
View File
@@ -80,7 +80,7 @@ Body: lane description / WIP policy / notes.
Body: the card's content — the whole point. Body: the card's content — the whole point.
Colors / icons are **lenient**: a malformed value is preserved verbatim and simply not displayed. **`width` is lenient too** (it styles layout; it isn't structure): anything but an integer ≥ 1 — zero, negative, fractional, non-numeric — is preserved verbatim and renders as the default 1. Fail-fast is reserved for structure (`schema`, `order`, YAML validity) — and it covers *malformed*, not just missing: an `order` that is present but non-numeric is the same loud malformed-input rejection as a missing one. Schema-owned display fields are Lanework's to interpret — **coerce where a sensible reading exists, fall back to the field's default where none does** (settled). A scalar of the wrong YAML type reads as its source text (`title: 2048` displays as "2048", `width: "2"` reads as 2); where no sensible reading exists — a sequence or mapping where a scalar belongs, a non-integer width, an unparseable timestamp — the field falls back to its default: untitled placeholder, width 1, no color, no icon. Coercion is read-side only; the bytes on disk are **preserved verbatim, never rewritten**. One tombstone nuance: any *present* `deleted:` key tombstones the item — an unusable timestamp still deletes, its date merely unknown (the user's intent to delete outranks the broken date). **Duplicate keys: last one wins** (settled — the coercing read; strict YAML would reject the file, so this is a deliberate divergence in the editor's favor): a key appearing twice reads as its last occurrence, earlier occurrences preserved verbatim on disk and invisible. An app write of a duplicated key rewrites the winning (last) occurrence and removes the earlier ones — the app owns the keys it writes, and leaving a stale twin would resurrect it if the winner were later removed; removing a key removes all its occurrences. **App rewrites preserve comments** (settled): comments on their own lines always survive a rewrite; an inline comment on a rewritten value line is re-spliced after the new value — best-effort, guaranteed for plain scalar lines (the realistic case), dropped only in pathological shapes. Fail-fast remains reserved for structure (`schema`, `order`, YAML validity) — and it covers *malformed*, not just missing: an `order` that is present but non-numeric is the same loud malformed-input rejection as a missing one.
## Enhanced schema (reserved, out of scope) ## Enhanced schema (reserved, out of scope)
@@ -115,7 +115,7 @@ Carried over unchanged — gapped fractional ranks:
## Malformed input — fail fast ## Malformed input — fail fast
Loud, specific error (path + what's wrong) for: unparseable YAML, missing required fields (`schema`; `order` where required), `schema` newer than the app, board root without `index.md`. No partial loads. The only tolerated absence is a missing `index.md` below the root (skip + warn, per Rules above). Loud, specific error (path + what's wrong) for: unparseable YAML, missing required fields (`schema`; `order` where required), `schema` newer than the app, board root without `index.md`. No partial loads. The only tolerated absence is a missing `index.md` below the root (skip + warn, per Rules above). An explicitly null value (`order:` with nothing after it, `order: null`) reads as **missing** (settled): on a required field that is the missing-required-field rejection — it describes what the hand-editor actually did, started the key and never gave it a value; on an optional field, null is simply absent and the default applies.
Fail-fast is the **initial-load** contract. Once a board is open, a failed live reload does not blank the board: the window keeps the last good snapshot and surfaces the same loud specifics in a non-modal banner — see 02-architecture.md's live-reload resilience. Fail-fast is the **initial-load** contract. Once a board is open, a failed live reload does not blank the board: the window keeps the last good snapshot and surfaces the same loud specifics in a non-modal banner — see 02-architecture.md's live-reload resilience.
+49 -18
View File
@@ -9,8 +9,9 @@ public struct FrontmatterField: Sendable, Equatable {
} }
/// The result of reading a typed field. `malformed` is what keeps strict fields (`schema`, /// The result of reading a typed field. `malformed` is what keeps strict fields (`schema`,
/// `order`) from being silently coerced and lenient fields (colors, icons, `width`) from /// `order`) from being silently coerced they fail the load instead. Lenient fields (colors,
/// erroring the loader decides which reaction each one gets. /// icons, `width`) coerce where a sensible reading exists (01-storage-format.md § Frontmatter)
/// and only fall back to `.malformed` rendered as the field's default when none does.
public enum FieldValue<Value: Sendable & Equatable>: Sendable, Equatable { public enum FieldValue<Value: Sendable & Equatable>: Sendable, Equatable {
case missing case missing
case valid(Value) case valid(Value)
@@ -42,12 +43,12 @@ extension FrontmatterDocument {
// MARK: - Strict (structure the loader fails fast on `.malformed`) // MARK: - Strict (structure the loader fails fast on `.malformed`)
public var schema: FieldValue<Int> { public var schema: FieldValue<Int> {
read(FrontmatterKeys.schema) { if case let .int(value) = $0 { value } else { nil } } read(FrontmatterKeys.schema) { value, _ in if case let .int(value) = value { value } else { nil } }
} }
public var order: FieldValue<Double> { public var order: FieldValue<Double> {
read(FrontmatterKeys.order) { read(FrontmatterKeys.order) { value, _ in
switch $0 { switch value {
case let .int(value): Double(value) case let .int(value): Double(value)
case let .double(value): value case let .double(value): value
default: nil default: nil
@@ -55,40 +56,70 @@ extension FrontmatterDocument {
} }
} }
// MARK: - Lenient (a malformed value is preserved and simply not used) // MARK: - Lenient (coerce where a sensible reading exists, else malformed UI default)
/// Any scalar coerces to the text the author typed a quoted string's own text (quotes and
/// escapes already resolved by the parser), or an unquoted scalar's exact source span
/// (`title: 2048` reads as `"2048"`). Only a sequence or mapping no scalar reading exists
/// is malformed.
public var title: FieldValue<String> { read(FrontmatterKeys.title, Self.string) } public var title: FieldValue<String> { read(FrontmatterKeys.title, Self.string) }
public var background: FieldValue<String> { read(FrontmatterKeys.background, Self.string) } public var background: FieldValue<String> { read(FrontmatterKeys.background, Self.string) }
public var icon: FieldValue<String> { read(FrontmatterKeys.icon, Self.string) } public var icon: FieldValue<String> { read(FrontmatterKeys.icon, Self.string) }
public var iconColor: FieldValue<String> { read(FrontmatterKeys.iconColor, Self.string) } public var iconColor: FieldValue<String> { read(FrontmatterKeys.iconColor, Self.string) }
/// Width multiplier; anything but an integer 1 is malformed and renders as the default 1. /// Width multiplier. An int stays; a string or double with an exact integer reading 1
/// coerces (`"2"`, `2.0` `2`). Everything else zero, negative, fractional, non-numeric,
/// bool, a sequence/mapping is malformed and renders as the default 1.
public var width: FieldValue<Int> { public var width: FieldValue<Int> {
read(FrontmatterKeys.width) { if case let .int(value) = $0, value >= 1 { value } else { nil } } read(FrontmatterKeys.width) { value, _ in
switch value {
case let .int(value): value >= 1 ? value : nil
case let .double(value): Self.exactIntWidth(value)
case let .string(text): Double(text).flatMap(Self.exactIntWidth)
default: nil
}
}
} }
public var created: FieldValue<Date> { read(FrontmatterKeys.created, Self.date) } public var created: FieldValue<Date> { read(FrontmatterKeys.created) { value, _ in Self.date(value) } }
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified, Self.date) } public var modified: FieldValue<Date> { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } }
public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted, Self.date) } public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } }
/// Schema-owned, not an unknown key: the app clears it on every app-mediated write. /// Schema-owned, not an unknown key: the app clears it on every app-mediated write.
public var modifiedBy: FieldValue<String> { read(FrontmatterKeys.modifiedBy, Self.string) } public var modifiedBy: FieldValue<String> { read(FrontmatterKeys.modifiedBy, Self.string) }
// MARK: - // MARK: -
private func read<Value>(_ key: String, _ transform: (YAMLValue) -> Value?) -> FieldValue<Value> { private func read<Value>(_ key: String, _ transform: (YAMLValue, String) -> Value?) -> FieldValue<Value> {
guard let value = value(for: key) else { return .missing } guard let value = value(for: key) else { return .missing }
if case .null = value { return .missing } if case .null = value { return .missing }
if let typed = transform(value) { return .valid(typed) } let raw = rawValue(for: key) ?? value.description
return .malformed(raw: rawValue(for: key) ?? value.description) if let typed = transform(value, raw) { return .valid(typed) }
return .malformed(raw: raw)
} }
private static func string(_ value: YAMLValue) -> String? { /// A quoted string reads as its own (already-unquoted, already-unescaped) text; any other
if case let .string(text) = value { return text } /// scalar reads as its raw source span what the author typed. Only a sequence/mapping has
return nil /// no sensible string reading.
private static func string(_ value: YAMLValue, raw: String) -> String? {
switch value {
case let .string(text): text
case .int, .double, .bool, .date: raw
default: nil
}
} }
/// A quoted timestamp reads the same as an unquoted one same YAML 1.1 timestamp grammar. /// An integer-valued double or numeric string 1 coerces; anything else (fractional,
/// non-numeric, out of `Int` range) has no sensible width reading.
private static func exactIntWidth(_ value: Double) -> Int? {
guard value.truncatingRemainder(dividingBy: 1) == 0, value >= 1, value <= Double(Int.max) else { return nil }
return Int(value)
}
/// A quoted timestamp reads the same as an unquoted one same YAML 1.1 timestamp grammar,
/// a superset of ISO-8601. Anything that isn't a valid timestamp either way has no sensible
/// date reading and is malformed; per the tombstone rule (`Lane`/`Card.isDeleted`), a
/// malformed `deleted` still deletes presence, not validity, is what counts.
private static func date(_ value: YAMLValue) -> Date? { private static func date(_ value: YAMLValue) -> Date? {
switch value { switch value {
case let .date(date): date case let .date(date): date
+21
View File
@@ -100,6 +100,27 @@ struct BoardLoaderWellFormedTests {
#expect(result.warnings.isEmpty) #expect(result.warnings.isEmpty)
} }
/// Tombstone semantics key on the `deleted` key's *presence*, not its validity
/// (01-storage-format.md § Frontmatter, § Deletion): a `deleted` value with no sensible
/// date reading still tombstones the user's intent to delete outranks the broken date.
@Test func malformedDeletedValueStillTombstonesLaneAndCard() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
try fixture.index("", "schema: 1\n")
try fixture.index("lane-1", "schema: 1\norder: 1024\ndeleted: yesterday\n")
try fixture.index("lane-1/card-1", "schema: 1\norder: 1024\ndeleted: yesterday\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
let lane = try #require(result.model.lanes.first { $0.id.rawValue == "lane-1" })
#expect(lane.deleted == .malformed(raw: "yesterday"))
#expect(lane.isDeleted)
let card = try #require(lane.cards.first { $0.id.rawValue == "card-1" })
#expect(card.deleted == .malformed(raw: "yesterday"))
#expect(card.isDeleted)
}
@Test func tiesAreBrokenByFolderNameNotTitle() throws { @Test func tiesAreBrokenByFolderNameNotTitle() throws {
let fixture = try BoardFixture() let fixture = try BoardFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
+35 -2
View File
@@ -345,11 +345,26 @@ struct FrontmatterLenientFieldTests {
#expect(try document("width: 1").width == .valid(1)) #expect(try document("width: 1").width == .valid(1))
} }
/// A scalar of the wrong YAML type still has a sensible string reading it coerces to the
/// source text the author typed (01-storage-format.md § Frontmatter). Only a sequence or
/// mapping no scalar to read at all is malformed.
@Test func scalarsOfTheWrongTypeCoerceToTheirSourceText() throws {
#expect(try document("title: 2048").title == .valid("2048"))
#expect(try document("title: true").title == .valid("true"))
#expect(try document("background: 42").background == .valid("42"))
#expect(try document("iconColor: true").iconColor == .valid("true"))
#expect(try document("icon: 2026-07-26T16:41:38Z").icon == .valid("2026-07-26T16:41:38Z"))
}
@Test func quotedStringLenientValuesAreUnaffectedByCoercion() throws {
#expect(try document("title: \"2048\"").title == .valid("2048"))
#expect(try document("title: \"true\"").title == .valid("true"))
#expect(try document("title: My Board").title == .valid("My Board"))
}
@Test func malformedLenientValuesArePreservedVerbatimAndDoNotThrow() throws { @Test func malformedLenientValuesArePreservedVerbatimAndDoNotThrow() throws {
#expect(try document("background: [red, blue]").background == .malformed(raw: "[red, blue]")) #expect(try document("background: [red, blue]").background == .malformed(raw: "[red, blue]"))
#expect(try document("background: 42").background == .malformed(raw: "42"))
#expect(try document("icon: {a: 1}").icon == .malformed(raw: "{a: 1}")) #expect(try document("icon: {a: 1}").icon == .malformed(raw: "{a: 1}"))
#expect(try document("iconColor: true").iconColor == .malformed(raw: "true"))
#expect(try document("title: [a, b]").title == .malformed(raw: "[a, b]")) #expect(try document("title: [a, b]").title == .malformed(raw: "[a, b]"))
} }
@@ -361,6 +376,17 @@ struct FrontmatterLenientFieldTests {
#expect(try document("schema: 1").width == .missing) #expect(try document("schema: 1").width == .missing)
} }
/// A string or double with an exact integer reading 1 coerces; a fractional reading,
/// zero, a negative, or non-numeric text still has none.
@Test func widthCoercesStringsAndWholeNumberDoubles() throws {
#expect(try document("width: \"2\"").width == .valid(2))
#expect(try document("width: 2.0").width == .valid(2))
#expect(try document("width: 2.7").width == .malformed(raw: "2.7"))
#expect(try document("width: 0").width == .malformed(raw: "0"))
#expect(try document("width: -1").width == .malformed(raw: "-1"))
#expect(try document("width: banana").width == .malformed(raw: "banana"))
}
@Test func malformedLenientValuesStillRoundTrip() throws { @Test func malformedLenientValuesStillRoundTrip() throws {
let text = "---\nschema: 1\nbackground: [red, blue]\nwidth: 0\nicon: {a: 1}\n---\nbody\n" let text = "---\nschema: 1\nbackground: [red, blue]\nwidth: 0\nicon: {a: 1}\n---\nbody\n"
let document = try FrontmatterDocument.parse(text) let document = try FrontmatterDocument.parse(text)
@@ -378,6 +404,13 @@ struct FrontmatterLenientFieldTests {
#expect(try document("schema: 1").deleted == .missing) #expect(try document("schema: 1").deleted == .missing)
#expect(try document("created: never").created == .malformed(raw: "never")) #expect(try document("created: never").created == .malformed(raw: "never"))
} }
/// A quoted ISO-8601 string is not YAML's implicit timestamp type it parses as `.string`
/// but still coerces to a valid date (01-storage-format.md § Frontmatter).
@Test func quotedISO8601StringCoercesToAValidDate() throws {
let expected = Date(timeIntervalSince1970: 1_767_323_045) // 2026-01-02T03:04:05Z
#expect(try document("created: \"2026-01-02T03:04:05Z\"").created == .valid(expected))
}
} }
// MARK: - Surgical edits // MARK: - Surgical edits