fix(frontmatter): a nested key must not substitute for a top-level one

The line-oriented frontmatter grammar exists in three copies, each with the
duplication documented at its site: `materialize` reads a path, `structure`
needs a character offset, `profiles` returns body lines. All three keyed on
`key.strip()`, which discards the indentation that is the only thing telling
a nested key from a top-level one. An indented `title:` under a `sources:`
block therefore landed in the same flat namespace as the document's own
`title:` and, arriving later, won.

The failure is substitution, not omission. A dropped value is visible to
whoever reads the concept; a substituted one is not -- the document carries a
title that looks entirely right and belongs to something else. Because
`number` derives from `title` and `parent` derives from `number`, one
substitution walks the hierarchy. Measured, not inferred: a document titled
`N100.2` with a nested source titled `N200.7` came back as N200.7 with parent
N200 instead of N100.2 with parent N100.

Measured incidence across the two corpora, denominators stated:
`_okf-canonical` @ ad30107, 54 documents with parsable frontmatter, 49 carry
a nested key colliding with a top-level name (90.7%); `_okf-upstream` @
9a15b13, 66 documents, 58 collide (87.9%). The colliding key is `title`, and
often `resource` with it -- in `acme_retail/tables/orders.md` the concept's
own BigQuery resource pointer was replaced by a nested one. This is a fix
that clears observed damage, not a hardening without a witness.

The fix refuses indented lines; it does not read them. Block form stays
unreadable -- `sources` and `verified` still come back empty -- so D4's
flow-form emission rule is untouched and the structured reader is still D1b.
Two characterization tests that pinned the old behaviour now pin the new: the
block-list family still DROPS its value, and only the key-space pollution is
gone. That family is not otherwise addressed here.

Test first, red before the code was touched, with known-positive controls for
all three parsers so that a parser returning nothing could not pass.

Order: 20260830T000740Z-4733930312-from-.claude

Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
Kjell Tore Guttormsen 2026-08-31 23:31:53 +02:00
commit 2337a328d9
6 changed files with 239 additions and 20 deletions

View file

@ -160,20 +160,26 @@ def test_the_contract_fields_round_trip_in_their_flow_form(tmp_path: Path) -> No
assert parse_frontmatter(path) == values
def test_two_nested_block_mappings_sharing_a_key_collide_in_the_scalar_parser(
def test_two_nested_block_mappings_sharing_a_key_are_refused_not_flattened(
tmp_path: Path,
) -> None:
"""Measured 2026-07-31, and the reason D4 stops at the flow form.
"""Measured 2026-07-31, re-measured 2026-08-31, and still the reason D4
stops at the flow form.
§10.2 presents `executor` and `attester` as nested BLOCK mappings, and both
carry a `resource`. The line-oriented parser has no indentation model, so it
flattens them into one namespace where the second `resource` overwrites the
first: `executor.resource` is lost and `attester.resource` surfaces as a
top-level key. No error is raised.
carry a `resource`. Neither is READABLE here -- both come back empty, which
is exactly why D4 pins emission to the flow form and why reading this shape
is D1b's work, not this parser's.
Pinned rather than fixed. Reading this form needs the structured reader
(D1b), and a half-reader that silently drops half a contract is worse than
one that never claimed to read it.
What the parser no longer does is FLATTEN them. Before order
`...4733930312` it had no indentation model at all: both `resource` lines
landed in the document's own namespace, the second overwrote the first, and
`attester.resource` surfaced as a top-level `resource` that no document
declared -- silently, with no error. A dropped value is visible to whoever
reads the concept; a substituted one is not.
So the contract is still half-read, and that is deliberate. It is now
half-read by REFUSAL rather than by substitution.
"""
path = tmp_path / "block-form.md"
path.write_text(
@ -192,8 +198,13 @@ def test_two_nested_block_mappings_sharing_a_key_collide_in_the_scalar_parser(
assert parsed["executor"] == ""
assert parsed["attester"] == ""
assert parsed["resource"] == "attesters/revenue.py"
# The substitution is gone: neither nested `resource` reaches the namespace.
assert "resource" not in parsed
assert "receipt" not in parsed
assert set(parsed) == {"type", "runtime", "executor", "attester"}
# Still unreadable, as D4 requires -- neither value survives anywhere.
assert "skills/run-on-bq.md" not in parsed.values()
assert "attesters/revenue.py" not in parsed.values()
# --- door C surfaces the §10 pointers it imports ---------------------------