Skip to content

Commit f57482e

Browse files
diegorvclaude
andcommitted
feat(properties): canonicalize YAML frontmatter on write path
Context: serializeProperties at src/lib/features/properties/properties.logic.ts emits the canonical YAML byte sequence consumed by every UI mutation path (Properties panel, lifecycle, icons, deep-link, meta-bind). The rules (quoting, empty handling, alias canonicalization, list flow style, key order, nested-object drop) were functionally centralized but ungoverned: no ADR, no machine-readable spec, no snapshot tests, yaml dep pinned with caret (^2.9.0), and external producers (Python person-note generator at koko.brain-os/vault/work/people/_generate.py) hand-rolled divergent quoting heuristics. Aliases (favorite/icon/is_a) were only normalized at parse time, so external producers writing non-canonical keys triggered silent disk diffs on the next UI touch. Nested mappings were dropped without log trail. The collection evaluator did not recognize is_a as a type identifier and compared type values case-sensitively, so `type == "person"` returned zero matches against a vault storing first-letter-uppercase type values. A Type Definition inside the configured system folder produced a sidebar section with definitionPath = null because TypeSidebar filtered entries before buildTypeSections, causing right-click "Create type definition" to silently duplicate the file at the vault root. Solution: Nine-task plan delivered as one squash: 1. Pin yaml@2.9.0 (drop caret) to freeze canonical form. 2. Extract shouldQuoteScalar/canonicalizeScalar to yaml-quoting.logic.ts with 130 parity tests vs yaml.stringify across mapping-value + flow-item contexts. Predicate is the documented contract + parity safety net; emitter still delegates to yaml.Document. 3. Apply canonicalizeKey inside serializeProperties before doc.set, so write path produces canonical keys regardless of construction path. 4. Log dropped nested mappings via appendLog('PROPERTIES', ...) so the silent data loss is now traceable. 5. Add 12 toBe snapshot tests pinning canonical form (emails bare, URN-style bare, wikilinks quoted, empty as "", lists flow style, reserved literals quoted, yes/no/on/off bare, native vs string-looking bool/number, key order preserved, realistic person-note end-to-end). 6. ADR 0029 documents canonical form end-to-end (scalar rules, empty, list flow, key order, alias canonicalization on parse AND write, documented drops, framing, trigger window, yaml version pin). 7. docs/specs/frontmatter-canonical-form.md normative spec with JSON rule block + predicate pseudocode + worked-examples table for external producers. 8-9. (External to this repo — Python generator rewrite + parity suite landed in the nested koko.brain-os/vault/ git repo as commits 0a3f38b and d8198dd.) Tangential fixes bundled in: - collection evaluator: IDENTIFIER_ALIASES map + canonicalPropertyName helper so is_a resolves to type in resolveIdentifier and resolveMember's note./ property. branches. isTypeIdentifier AST predicate + case-insensitive branch in evaluateBinary for == / != against type/is_a (other fields stay case-sensitive). - type-definitions: promote on-disk location to TypeMetadata.path field populated in extractTypeMetadata. buildTypeSections drops typeDefPaths Map and reads metadata.path directly, so definitionPath resolves correctly when the Type entry was filtered out (system folder case). Behavior: Frontmatter write path now emits canonical bytes regardless of caller construction. Nested-mapping drops appear in session log. Pinned yaml@2.9.0 refuses silent minor bumps. `type == "person"`, `type == "Person"`, `is_a == "person"`, `note.type == "PERSON"` all match the same notes; other field comparisons stay case-sensitive. Type Definitions in system folders show the standard right-click menu (Open / Copy path / Change icon / Reveal in Finder) instead of "Create type definition", eliminating the duplicate-file footgun. No user-visible change to existing notes already in canonical form. Files: - package.json:74, pnpm-lock.yaml:170-172: yaml pin "^2.9.0" -> "2.9.0". - src/lib/features/properties/yaml-quoting.logic.ts:1-167: New module with shouldQuoteScalar/canonicalizeScalar + RESERVED_LITERALS / MUST_QUOTE_LEADING_CHARS / SCALAR_MUST_QUOTE_SUBSTRINGS / FLOW_ITEM_MUST_QUOTE_SUBSTRINGS exports. - src/lib/features/properties/properties.logic.ts:1-4,132-145,192-219: appendLog import; nested-mapping drop now logs via PROPERTIES tag; canonicalizeKey applied before each doc.set with JSDoc rationale. - src/lib/features/collection/expression/evaluator.ts:85-244: IDENTIFIER_ALIASES, canonicalPropertyName, isTypeIdentifier; resolveIdentifier + resolveMember alias lookup; evaluateBinary case-insensitive == / != branch for type/is_a. - src/lib/features/type-definitions/type-definitions.logic.ts:7-95: TypeMetadata.path field (string | null); DEFAULTS.path = null; extractTypeMetadata returns entry.path. - src/lib/features/type-definitions/type-sidebar.logic.ts:268-305: buildTypeSections reads metadata.path; removed typeDefPaths Map and the second iteration over entries. - docs/adr/0029-frontmatter-yaml-canonical-form.md:1-113, docs/adr/README.md:135: New ADR + index row. - docs/specs/frontmatter-canonical-form.md:1-183: New normative spec (producer checklist, JSON rule block, predicate pseudocode, worked- examples table, person-note snapshot). - src/tests/lib/features/properties/yaml-quoting.logic.test.ts:1-310: 130 tests (mapping-value rules, flow-item rules, canonicalizeScalar emission, parity vs yaml.stringify). - src/tests/lib/features/properties/properties.logic.test.ts:1-571: vi.mock for log.service; _resetParseFrontmatterCache import; warn-on-drop tests; serializeProperties alias canonicalization tests; 12 canonical-form snapshot tests. - src/tests/lib/features/collection/expression/evaluator.test.ts:198-286: "frontmatter aliases (is_a -> type)" + "case-insensitive equality for type / is_a" describe blocks (13 tests covering bare identifier, note./property. member access, !=, missing-property null, control case `name == "alice"`). - src/tests/lib/features/type-definitions/{type-definitions.logic,type-sidebar.logic,type-definitions.service,type-definitions.store}.test.ts, src/tests/lib/features/auto-move/type-lifecycle-rules.test.ts: meta() factories add path field; new tests for path-from-entry + fallback null + system-folder regression scenario. - help/documentation/{12-collection,25-types-and-relationships}.md: Documented is_a alias + case-insensitive type equality. - tasks/done/frontmatter-canonical-form.md, tasks/done/filter-expression-type-ergonomics.md: Plan trackers moved to done with task SHAs + nested-repo notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 07fdc02 commit f57482e

22 files changed

Lines changed: 1280 additions & 17 deletions
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
---
2+
type: ADR
3+
id: "0029"
4+
title: "Canonical YAML form for frontmatter on the write path"
5+
status: active
6+
date: 2026-05-31
7+
---
8+
9+
## Context
10+
11+
Every UI mutation that touches frontmatter (Properties panel, lifecycle toggles, meta-bind widgets, frontmatter-icon picker, deep-link writers, type-definitions service) eventually re-serializes the whole frontmatter block via `serializeProperties` + `rebuildContent` (`src/lib/features/properties/properties.logic.ts:202` and `:218`). The serializer delegates the per-scalar quoting decision to `yaml@2.9.0` with `{ lineWidth: 0, flowCollectionPadding: false }`. ADR 0027 already covers the underscore-prefix + alias convention for keys, but not the value-side rules — quoting, empty handling, list flow style, key order, drops.
12+
13+
External producers also write frontmatter directly. The motivating case is `koko.brain-os/vault/work/people/_generate.py`, a Python script that materialises 105 person notes from a JSON source. Its hand-rolled quoting heuristic disagreed with yaml@2.9.0 (it over-quoted `@` and `:` inside strings), guaranteeing a one-shot diff against any file the app had touched, and breaking the "re-run generator = zero diff" idempotence we depend on for safe regeneration.
14+
15+
We need: a documented contract for the canonical form, codified in tests, locked against silent yaml-version drift, exported in a form a non-TypeScript producer can read.
16+
17+
## Decision
18+
19+
**The canonical frontmatter byte sequence emitted by Kokobrain is defined by `serializeProperties` at `src/lib/features/properties/properties.logic.ts:202` running against yaml@2.9.0 (pinned, no caret) with `{ lineWidth: 0, flowCollectionPadding: false }`. The exact rules below are reproduced as a pure pre-emission predicate at `src/lib/features/properties/yaml-quoting.logic.ts` (`shouldQuoteScalar`) and pinned by 12 byte-level snapshot tests + 130 predicate parity tests.**
20+
21+
### Quoting (scalar values)
22+
23+
A string scalar is **double-quoted** iff any of the following is true. Otherwise it is bare.
24+
25+
| Trigger | Example -> emitted |
26+
| --- | --- |
27+
| Empty string | `""` |
28+
| Leading char in `[`, `]`, `{`, `}`, `!`, `&`, `*`, `>`, `\|`, `@`, `` ` ``, `'`, `"`, `#`, `%`, `,` | `[[Foo]]` -> `"[[Foo]]"`, `@foo` -> `"@foo"` |
29+
| Leading `?` or `-` followed by space/tab | `? key` -> `"? key"` |
30+
| Lone `~` | `~` -> `"~"` |
31+
| Leading or trailing space/tab | `" leading"` -> `" leading"` |
32+
| Trailing `:` | `foo:` -> `"foo:"` |
33+
| Substring `": "` (colon + space or tab) | `foo: bar` -> `"foo: bar"` |
34+
| Substring `" #"` or `"\t#"` | `foo #x` -> `"foo #x"` |
35+
| Reserved literal: `true`, `false`, `null`, `Null`, `NULL`, `True`, `False`, `TRUE`, `FALSE`, `~` | `true` -> `"true"` |
36+
| Parses as YAML core number (decimal, hex, octal, float, scientific, `.inf`, `.nan`) | `42` -> `"42"`, `.inf` -> `".inf"` |
37+
| Contains control char (`\x00-\x08`, `\x0b`, `\x0c`, `\x0e-\x1f`, `\x7f`) | -> quoted |
38+
| Contains `\n` or `\r` | emitted as block scalar (`\|-`) |
39+
40+
Bare survives for:
41+
- `:` mid-string with no following space (URN-style values like `urn:example:identity:uuid:abc`).
42+
- `@` mid-string or trailing (emails like `foo@bar.com`, `foo@`).
43+
- ISO date-like strings (`2026-05-31`, `2026-05-31T12:00:00`).
44+
- `yes`, `no`, `on`, `off` (NOT reserved in YAML 1.2 core).
45+
- Mid-string tabs.
46+
- `100%`, `foo%bar` (only leading `%` quotes).
47+
- `?nospace`, `-nospace` (only `?`/`-` + space quotes).
48+
- `~tilde` (only lone `~` quotes).
49+
50+
Inside a flow-sequence item (`[a, b]`), the predicate is stricter: any `,` forces quoting because the comma is the flow-sequence separator. So `foo, bar` is bare in mapping context but quoted in list context.
51+
52+
Quote style: always **double-quoted**. yaml@2.9.0 sometimes picks single-quotes (for values containing `"` but no `'`); `canonicalizeScalar` always emits double-quotes. Both forms are valid YAML and round-trip to the same value; the discrepancy only matters for byte-identical comparison on values containing literal `"`, which do not appear in normal vault frontmatter (wikilinks, URNs, emails carry none).
53+
54+
### Empty values
55+
56+
`key:` with no value parses as null. `convertToProperty` at `properties.logic.ts:65-67` coerces null/undefined to `{ type: 'text', value: '' }`, which the serializer emits as `key: ""`. An empty list is emitted as `key: []`. The Properties type system (`_system/types/*.md`) is UI-side only and does not influence disk form.
57+
58+
### Lists
59+
60+
Always flow-style: `key: [a, b, c]`. The separator is comma + space; no padding inside the brackets. Each item is requoted by the same scalar rules as above, but in flow-item context (so `foo, bar` items would quote where they would not as a mapping value). A single-item list also uses flow style: `key: [only]`. List items are coerced to strings: numbers, booleans, dates, and objects all become strings via `String(item)` / `JSON.stringify(item)` at `properties.logic.ts:46-55`.
61+
62+
### Key order
63+
64+
Input order is preserved. `serializeProperties` iterates the Property[] in array order and emits one `doc.set(key, value)` per iteration; JS preserves Object insertion order, so a key inserted between `_archived` and `created` stays there round-trip.
65+
66+
### Aliases
67+
68+
Every key is passed through `canonicalizeKey` (`src/lib/utils/frontmatter-aliases.ts:30`) on both the parse path (`properties.logic.ts:135`) and the write path (`properties.logic.ts:208`). External producers writing alias keys (`favorite`, `icon`, `is_a`, …) see them normalised to canonical (`_favorite`, `_icon`, `type`, …) on the next disk round-trip.
69+
70+
### Drops
71+
72+
Nested mapping values are not representable in the Properties panel and are dropped during parse at `properties.logic.ts:132-145`. The drop is logged via `appendLog('PROPERTIES', …)` so the data loss is traceable in the session log; the behaviour is preserved (no throw, no UI surface).
73+
74+
YAML comments are dropped on round-trip — yaml@2.9.0 with our options does not preserve them through `Document.toString`. This is a known limitation of the library, not a Kokobrain decision; treat frontmatter as machine-managed and put long-form prose in the body.
75+
76+
### Framing
77+
78+
`rebuildContent` at `properties.logic.ts:218-225` wraps the serializer output as `---\n${yaml}\n---\n${body}`. Line endings are LF only; the parser accepts `\r?\n` but the writer always emits LF. No BOM, no trailing whitespace per line.
79+
80+
### Trigger window
81+
82+
Normalization does NOT fire on open (`editor.service.ts:58-119` reads the file verbatim) and does NOT fire on save (`editor.service.ts:133-155` writes verbatim). It fires only on UI mutations that re-serialize via `rebuildContent`: Properties panel commit, lifecycle service, frontmatter-icon service, deep-link writers, type-definitions view editor, meta-bind input widgets. A file written by an external producer and never touched through the UI keeps its bytes intact.
83+
84+
### Version pinning
85+
86+
The `yaml` dependency is pinned to exact `2.9.0` in `package.json` (no caret). Any future upgrade must be an explicit edit accompanied by a re-run of the canonical-form snapshot tests and a sync with the external Python generator. The 130 parity tests at `src/tests/lib/features/properties/yaml-quoting.logic.test.ts` catch drift.
87+
88+
## Alternatives considered
89+
90+
- **Use the predicate to actually emit, replacing yaml.Document**. Strictly safer but doubles the surface area: we would own both the predicate and the emitter and would have to keep the predicate in sync with itself across mapping and flow contexts, escape sequences, block scalars, etc. The current split (predicate documents + parity-tests yaml's behaviour, yaml emits) keeps the implementation small.
91+
- **Sort keys alphabetically on write**. Considered briefly; rejected because deterministic order ranks alpha over semantic grouping (`type` -> `_organized` -> `_archived` -> `_favorite` -> `created` -> domain fields is the human-friendly order; alphabetisation would scatter system flags into the middle of domain fields).
92+
- **Allow yaml's single-quote choice through**. Rejected for the Python generator side: Python emits double-quotes uniformly, and matching yaml's edge-case single-quote pick would force the generator to embed yaml-policy state. The Python side is the source of truth for these rare cases; if yaml emits `'foo'` and Python emits `"foo"`, the next UI touch re-canonicalises to whatever yaml picks.
93+
- **Preserve YAML comments**. Not feasible with yaml@2.9.0 + `Document.toString` for our shape; would require a different library or a manual emitter.
94+
95+
## Consequences
96+
97+
- The Python generator at `koko.brain-os/vault/work/people/_generate.py` is rewritten in lockstep with this ADR to replicate these rules. Other external producers must do the same.
98+
- The machine-readable spec at `docs/specs/frontmatter-canonical-form.md` is the consumable contract; this ADR is the rationale.
99+
- Any future `yaml` upgrade is an intentional canonical-form change that must update the snapshot tests, the predicate, the spec, and the Python generator together.
100+
- Nested mappings stay dropped (with a session-log breadcrumb). YAML comments stay dropped. Both are limitations callers must accept.
101+
102+
## Citation map
103+
104+
- Serializer entry: `src/lib/features/properties/properties.logic.ts:202` (`serializeProperties`).
105+
- Frame wrapper: `:218` (`rebuildContent`).
106+
- Predicate: `src/lib/features/properties/yaml-quoting.logic.ts` (`shouldQuoteScalar`, `canonicalizeScalar`).
107+
- Parse-time alias resolution: `properties.logic.ts:135`.
108+
- Write-time alias resolution: `properties.logic.ts:208`.
109+
- Empty-value coercion: `properties.logic.ts:65-67`.
110+
- Nested-object drop + warn: `properties.logic.ts:132-145`.
111+
- Snapshot tests: `src/tests/lib/features/properties/properties.logic.test.ts` describe `canonical form snapshot (serializeProperties)`.
112+
- Predicate + parity tests: `src/tests/lib/features/properties/yaml-quoting.logic.test.ts`.
113+
- Pinned yaml version: `package.json:74`.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,4 @@ Examples: `0001-tauri-svelte-sveltekit-stack.md`, `0009-incremental-indexing-rev
133133
| [0026](0026-type-definitions-relationships-lifecycle.md) | Type definitions, semantic relationships, and lifecycle flags via frontmatter | active |
134134
| [0027](0027-frontmatter-system-metadata-underscore-prefix.md) | Underscore prefix convention for system metadata with Rust-side alias resolution | active |
135135
| [0028](0028-quick-capture-merge-into-kokobrain.md) | Merge quick-capture surface into kokobrain — composer popover + clipboard shortcut | active |
136+
| [0029](0029-frontmatter-yaml-canonical-form.md) | Canonical YAML form for frontmatter on the write path | active |
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# Frontmatter Canonical Form Spec (machine-readable)
2+
3+
This file is the contract any external producer of Kokobrain frontmatter must implement. The rationale, alternatives, and citation map live in [ADR 0029](../adr/0029-frontmatter-yaml-canonical-form.md). This file is the consumable spec.
4+
5+
The canonical form is what `serializeProperties` at `src/lib/features/properties/properties.logic.ts:202` emits when fed a `Property[]`. Producers that match this form keep the "re-run = zero diff" idempotence guarantee. Producers that diverge will see their files silently rewritten on the first UI mutation.
6+
7+
## Producer checklist
8+
9+
1. Apply `aliases` map to every input key BEFORE emitting.
10+
2. For each key/value, decide bare vs double-quoted via the predicate below.
11+
3. Emit empty values as `key: ""`.
12+
4. Emit lists as flow style `key: [a, b, c]`, comma+space separator, no inner padding, items requoted in flow-item context.
13+
5. Preserve input key order. No sort.
14+
6. Drop nested mapping values. Warn / log.
15+
7. Wrap the block as `---\n…\n---\n${body}`. LF line endings only. No BOM. Strip trailing whitespace from each emitted line.
16+
8. UTF-8.
17+
18+
## Rules (JSON)
19+
20+
The block below is normative. Drop into a JSON parser and consume directly.
21+
22+
```json
23+
{
24+
"yamlLibraryVersion": "2.9.0",
25+
"encoding": "utf-8",
26+
"lineEnding": "\n",
27+
"bom": false,
28+
"framing": {
29+
"open": "---\n",
30+
"close": "\n---\n"
31+
},
32+
"yamlOptions": {
33+
"lineWidth": 0,
34+
"flowCollectionPadding": false
35+
},
36+
"aliases": {
37+
"is_a": "type",
38+
"is a": "type",
39+
"organized": "_organized",
40+
"archived": "_archived",
41+
"favorite": "_favorite",
42+
"order": "_order",
43+
"favorite_index": "_favorite_index",
44+
"sort": "_sort",
45+
"icon": "_icon",
46+
"sidebar_label": "_sidebar_label",
47+
"sidebar label": "_sidebar_label",
48+
"color": "_color",
49+
"title_color": "_title_color",
50+
"template": "_template",
51+
"view": "_view",
52+
"visible": "_visible",
53+
"list_properties_display": "_list_properties_display"
54+
},
55+
"keyOrder": "preserve-input",
56+
"scalar": {
57+
"quoteStyle": "double",
58+
"emptyValue": "\"\"",
59+
"mustQuoteIf": {
60+
"isEmpty": true,
61+
"leadingChars": ["[", "]", "{", "}", "!", "&", "*", ">", "|", "@", "`", "'", "\"", "#", "%", ","],
62+
"leadingIndicatorPlusSpace": ["? ", "?\t", "- ", "-\t"],
63+
"loneTilde": true,
64+
"leadingWhitespace": [" ", "\t"],
65+
"trailingWhitespaceOrColon": [" ", "\t", ":"],
66+
"substringsAnywhere": [": ", ":\t", " #", "\t#"],
67+
"reservedLiterals": ["true", "false", "null", "Null", "NULL", "True", "False", "TRUE", "FALSE", "~"],
68+
"numericLikeRegex": "^[-+]?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?(?:[eE][-+]?[0-9]+)?$|^[-+]?\\.(?:inf|nan)$|^[-+]?\\.[0-9]+(?:[eE][-+]?[0-9]+)?$|^0x[0-9a-fA-F]+$|^0o[0-7]+$",
69+
"controlCharsRegex": "[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]",
70+
"newline": true
71+
},
72+
"bareAlwaysAllowedFor": {
73+
"midStringColonNoSpace": "URN-style values like urn:example:identity:uuid:abc stay bare",
74+
"midStringAt": "Emails like foo@bar.com stay bare; only leading @ quotes",
75+
"trailingAt": "foo@ stays bare",
76+
"isoDateLikeRegex": "^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}(:\\d{2}(\\.\\d+)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$",
77+
"informalBools": ["yes", "no", "on", "off", "Yes", "No", "Off", "On"],
78+
"midStringTab": "Tabs mid-string do not quote",
79+
"midOrTrailingPercent": "100% and foo%bar stay bare; only leading % quotes",
80+
"indicatorWithoutSpace": "?nospace and -nospace stay bare",
81+
"tildeNotAlone": "~tilde and foo~ stay bare; only lone ~ quotes"
82+
},
83+
"quoteEscaping": {
84+
"double": {
85+
"backslash": "\\\\",
86+
"doubleQuote": "\\\"",
87+
"note": "Producers should always emit double-quoted form. yaml@2.9.0 sometimes picks single-quotes (for values containing \" but no '); producers that always emit double-quotes will diverge only on values that contain a literal \", which do not appear in normal vault frontmatter (wikilinks, URNs, emails carry none)."
88+
}
89+
}
90+
},
91+
"flowItem": {
92+
"additionalMustQuoteSubstrings": [","],
93+
"note": "Inside [a, b], any comma forces quoting because comma is the flow-sequence separator. So foo,bar quotes in flow context but stays bare in mapping context."
94+
},
95+
"list": {
96+
"style": "flow",
97+
"open": "[",
98+
"close": "]",
99+
"separator": ", ",
100+
"innerPadding": false,
101+
"empty": "[]",
102+
"itemCoercion": "string",
103+
"itemQuotingContext": "flow-item"
104+
},
105+
"nestedMappings": {
106+
"behavior": "drop",
107+
"warn": true,
108+
"warnTag": "PROPERTIES"
109+
},
110+
"yamlComments": {
111+
"behavior": "drop",
112+
"reason": "yaml@2.9.0 with our options does not round-trip comments through Document.toString"
113+
},
114+
"triggerWindow": "ui-mutation-only",
115+
"openNormalizes": false,
116+
"saveNormalizes": false
117+
}
118+
```
119+
120+
## Predicate pseudocode (mapping-value context)
121+
122+
```
123+
function shouldQuote(value):
124+
if value == "": return true
125+
if value in RESERVED_LITERALS: return true
126+
if matches NUMERIC_LIKE_REGEX: return true
127+
if matches ISO_DATE_LIKE_REGEX: return false
128+
if value[0] in MUST_QUOTE_LEADING_CHARS: return true
129+
if (value[0] == '?' or value[0] == '-')
130+
and len(value) > 1
131+
and value[1] in (' ', '\t'): return true
132+
if value[0] == '~' and len(value) == 1: return true
133+
if value[0] in (' ', '\t'): return true
134+
if value[-1] in (' ', '\t', ':'): return true
135+
for sub in [': ', ':\t', ' #', '\t#']:
136+
if sub in value: return true
137+
if matches CONTROL_CHAR_REGEX: return true
138+
if '\n' in value or '\r' in value: return true
139+
return false
140+
```
141+
142+
For flow-item context: add `if ',' in value: return true` at the end (before the final `return false`).
143+
144+
## Worked examples
145+
146+
| Input value (string) | Mapping emit | Flow-item emit |
147+
| --- | --- | --- |
148+
| `foo@bar.com` | `foo@bar.com` | `foo@bar.com` |
149+
| `urn:example:identity:uuid:abc` | `urn:example:identity:uuid:abc` | `urn:example:identity:uuid:abc` |
150+
| `[[Foo-Bar]]` | `"[[Foo-Bar]]"` | `"[[Foo-Bar]]"` |
151+
| `` `` (empty) | `""` | `""` |
152+
| `42` (string) | `"42"` | `"42"` |
153+
| `42` (number) | `42` | `42` |
154+
| `true` (string) | `"true"` | `"true"` |
155+
| `true` (boolean) | `true` | `true` |
156+
| `2026-05-31` | `2026-05-31` | `2026-05-31` |
157+
| `yes` | `yes` | `yes` |
158+
| `foo: bar` | `"foo: bar"` | `"foo: bar"` |
159+
| `foo, bar` | `foo, bar` | `"foo, bar"` |
160+
| `foo,bar` | `foo,bar` | `"foo,bar"` |
161+
| `100%` | `100%` | `100%` |
162+
| `~tilde` | `~tilde` | `~tilde` |
163+
| `~` | `"~"` | `"~"` |
164+
165+
## Realistic person-note snapshot
166+
167+
```
168+
---
169+
type: person
170+
_organized: "true"
171+
_archived: "false"
172+
created: 2026-05-31
173+
name: Jane Doe
174+
email: jane.doe@example.com
175+
ident_id: urn:example:identity:uuid:00000000-0000-0000-0000-000000000000
176+
end_at: ""
177+
expire_at: ""
178+
_belongs_to: "[[Some-Team]]"
179+
_reports_to: "[[John-Smith]]"
180+
---
181+
```
182+
183+
This is the exact expected output of `serializeProperties` for the corresponding `Property[]` and is pinned as a snapshot test in `src/tests/lib/features/properties/properties.logic.test.ts` (`canonical form snapshot` describe block, last case).

0 commit comments

Comments
 (0)