# Conversation Transcript - Write-Once Frontmatter Fields and touch --set Session > Source: Claude Code session (`claude-opus-5`), llm-wiki-test1 workspace > Collected: 2026-08-31 > Participant: Torben > Fidelity: **faithful summary transcript, not a verbatim log.** Torben's three design > decisions are recorded verbatim as the options he was shown and the ones he chose; command > output quoted below is real; the assistant's reasoning is condensed. > No second-hand material. No credentials appeared in the session. > One of two transcripts cut from this stretch of the session; the other covers the > Mass-Update Gate's counting rule and the measured iteration-budget calibration. Covers Gitea issue #14, implemented as `touch --set/--add/--remove` in stack `1.4.0` (`dbe2f73`). Issue #14 closed, #16 opened for the part deliberately left out. --- ## Turn 1 - "Führe 14 aus und lege mir die nötigen Entscheidungen jeweils mit Hintergrund vor" The assistant read `touch.py` before proposing anything, and found the groundwork already in place: `validate_fields()` validates **per field** rather than per document, which is exactly what a `--set` needs. Its docstring explains why - whole-document validation would refuse to bump `modified:` on a page that is invalid for some unrelated reason, "which is exactly the page most in need of maintenance". Three decisions were put to Torben, each with the trade-off spelled out. ### Decision 1 - which fields may `--set` write? | Option | Consequence | |---|---| | **Denylist** (chosen) | Everything the schema declares, minus a short reasoned block list. A field added to a type-spec works immediately. | | Allowlist | A maintained set of writable fields. Safer against surprises, but every new schema field needs a code change. | | Denylist minus the already-flagged fields | Also blocks `summary`/`provenance`/`confidence-base`, which have their own options. Prevents two ways to do one thing, at the cost of a surprising refusal. | **Torben chose the denylist.** The argument that carried it: an allowlist is a second copy of the schema, and the copy is the one that drifts - AGENTS.md invariant 8 applied to a constant. Blocked, each naming the command that owns the field instead: - `type:` - changes the page's schema *and* its directory; that is `page-lifecycle.md`. - `confidence:` - derived from `confidence_base` by decay, not authored. - `related:`/`sources:`/`entities:`/`concepts:` - `xref` maintains the reverse direction and the body bullets; a bare frontmatter write would leave the other half stale. ### Decision 2 - what does `--set tags=a,b` mean for a list that already has values? | Option | Consequence | |---|---| | Replace only | One rule. Adding a single tag means naming the whole list. | | **Replace plus `--add`/`--remove`** (chosen) | Three options instead of one, but a single element can be changed without reading the list first. Leaves open what `--remove` does with an absent element. | **Torben chose replace plus `--add`/`--remove`.** The open sub-question - `--remove` on an element that is not there - the assistant decided itself and said so: **it succeeds and reports it.** Idempotent like `xref remove`, because a repair command that refuses to run twice is one nobody dares script; but never silent, because a silent no-op looks exactly like a successful removal, and that is how a mistyped element name hides. ### Decision 3 - does this pass also deliver `raw rename`? | Option | Consequence | |---|---| | **`touch --set` only** (chosen) | Stays `size/S`. The file move becomes possible in two steps (`git mv`, then `touch --set raw_files=…`). The remaining gap gets its own issue. | | Both in one release | Closes #14 fully and removes the intermediate state, at `size/M`: backward search over all `raw_files:` references, behaviour with multiple owners, contract lines for two commands. | **Torben chose the narrower pass.** `raw rename` became issue #16 (`prio/2`, `size/S`). --- ## Turn 2 - implementation ### What moved `_coerce_set_value`, `_parse_set_fields` and `_check_raw_files_exist` left `new_page.py` for `commands/_util.py` and lost their leading underscores. Two commands, one implementation - otherwise `touch --set` would have inherited the comma bug from #12 on day one. A test covers exactly that: an existing raw file whose name contains a comma, referenced through `\,`, read back as a single path. `raw_files:` written by `touch` gets the same filesystem existence check `new` performs. It is I/O, not a data shape, so no schema can express it. ### Two refusals, deliberately worded differently A blocked field is a routing problem, so the message names the command that owns it. An unknown field is a typo or the wrong page type, so the message lists what the page actually has - the value is learning that `tags` was meant: ``` $ tools/wikitool touch --page "Diff-Reviewable Agent Edits" --set "sources=Source - X" ERROR `sources` cannot be set with --set: page-reference field - use `wikitool xref add` / `xref remove` $ tools/wikitool touch --page "Diff-Reviewable Agent Edits" --set "tag=x" ERROR Type types/concept.md declares no field 'tag'. Settable fields for this page: concept_type, confidence_base, created, modified, provenance, summary, tags ``` ### A test-harness trap, fixed once The existing `test_touch.py` called the Typer callback directly with a full argument list, so three new options broke seven call sites with `TypeError: 'OptionInfo' object is not iterable`. A callback invoked directly receives `OptionInfo` objects for whatever the caller omits - the same hazard `dist_cmd.py` avoids by keeping its logic beside the wrapper. The tests now go through a `_touch(**overrides)` helper that supplies every option, so the next option costs one line rather than seven. --- ## Turn 3 - the repair that proves it `kb/concepts/Diff-Reviewable Agent Edits.md` had been created hours earlier by an ingest whose `--set tags=` value carried a trailing comma; everything after the separator was lost. That subagent correctly worked through all three ways out and rejected each: `touch` could not set `tags:`, hand-editing frontmatter comes too close to invariant 1, and `rm` + `new` would have broken the `concepts:` reference the source page already held. The page kept `[agent-workflow]` permanently, because of a comma. Against the real corpus: ``` $ tools/wikitool touch --page "Diff-Reviewable Agent Edits" --add "tags=context-engineering,tooling" tags: added 'context-engineering', 'tooling' OK Touched kb/concepts/Diff-Reviewable Agent Edits.md $ tools/wikitool touch --page "Diff-Reviewable Agent Edits" --add "tags=context-engineering" --no-date OK 'Diff-Reviewable Agent Edits' already up to date; nothing to change. $ tools/wikitool touch --page "Diff-Reviewable Agent Edits" --remove "tags=vertippt" --no-date tags: not present, nothing removed: 'vertippt' OK 'Diff-Reviewable Agent Edits' already up to date; nothing to change. ``` The page now carries `[agent-workflow, context-engineering, tooling]`. --- ## The defect this closed, stated once A field `new` wrote once - `tags:`, `raw_files:`, `source_url:` - was afterwards unreachable. `touch` did not know it; hand-editing frontmatter is what the tool exists to prevent; deleting and recreating the page breaks every reference already pointing at it. And `new` is **not idempotent**, so the window to get the value right was exactly one command wide. The evidence that this was a rate rather than an accident: three failures across three consecutive ingests on the same day, at two different fields, by three different agents. One of them was a trailing comma. --- ## Outcome | Artifact | Result | |---|---| | Stack version | `1.4.0` (MINOR - new capability, backwards compatible) | | Commit | `dbe2f73` - 9 files | | Tests | 674 passing, in the normal and the hardened environment | | Gitea | #14 closed with the three decisions recorded; #16 opened (`prio/2`, `size/S`) | | Corpus | The unrepairable page repaired |