1
0
Fork 0

feat(okf): a flow sequence of plain scalars parses, and the corpus number is 6/53

`tags: [a, b, c]` is the form SPEC.md 4.1's own frontmatter skeleton writes out,
and 9/53 upstream reference concepts use it. It raised on the `[` indicator.
It parses now, to the same list its block-sequence sibling already produced.

The predicate is character-level, inside `_parse_flow_sequence`: an element is a
plain scalar only if it is non-empty and carries none of `{ } [ ] : , " ' #`,
and it then passes the unchanged scalar-indicator rule. Everything that would
need YAML semantics to split or unquote still raises - a quoted element (quotes
are retained here, never stripped), a colon or comma inside an element, a
sequence inside a sequence, an empty element, an anchor, an alias. A sequence
may not mix scalars and mappings, the rule the block list already carries, and
the mixing verdict is reached before the element is parsed so the caller is told
about the mix rather than about a key the allowlist would have named instead.
The 1.3.0 `sources` flow-mapping carrier is unchanged and pinned against
regression. Depth 1 is not spent: the elements are leaves.

Measured with the denominator, against the pinned corpus (`_okf-upstream` @
3fcbb9f, 53 non-reserved documents) and the pinned SPEC (`_okf-canonical` @
ad30107). Baseline reproduced first, with a known-positive control, at 0/53.
After: 6/53, all six in acme_retail. It does not close the corpus - 44/53 still
stop on `generated` written as a top-level block mapping, which spends the
no-nesting-past-depth-1 rule and is a security decision, out of scope here.

P1 alone, per the operator decision of 08.09. The two neighbouring predicates
were measured and deliberately not built: a flush-left block sequence and a
folded plain scalar release 0/53 each on their own, and stacked on this one they
still measure 6/53. `_consume_block_list`, the `description` continuation and
the allowlist are untouched.

docs/LIMITATIONS.md's tags/description entry is rewritten against the
measurement: three of its claims were wrong. The parser does have a
sequence-value type (since 1.3.0 - what it lacks is the indentation the corpus
omits); the figure is 6/53, not the 1.2.0-era 4/53; and tags/description are
not the residual that blocks the corpus. README gains the sequence carrier in
the paragraph that already describes the mapping one.

Self-safety: the predicate compiles no regex, so docs/redos-sweep.py cannot see
it. Measured instead on the CPU clock - linear in element length (exponent
0.86-0.99) and in element count (0.97-1.05) over four doublings to 800_000 -
and pinned by two bounds in tests/test_okf.py.

Six rows that pinned the old refusal are re-aimed at the class that still
holds - the quoted element - the way the 1.3.0 rows were when the carrier
opened. One of them lives in src/llm_ingestion_guard/coverage.py, which is why
the src diff is three files rather than one.

Version 1.4.0 in the code only. The CHANGELOG entry stays under Unreleased and
no tag is cut: README's badge and install pin must keep naming a tag that
exists.

Gates after `git add`: 893 passed (was 868), coverage 130/130 + 6/6 gaps,
redos-sweep exit 0, LIMITATIONS still 45 entries.
This commit is contained in:
Kjell Tore Guttormsen 2026-09-08 05:34:55 +02:00
commit 3e324a1f86
9 changed files with 341 additions and 59 deletions

View file

@ -116,9 +116,13 @@ def test_rejects_unterminated_frontmatter():
def test_rejects_flow_collection():
# inline flow collections are outside the supported subset -> reject, don't
# silently mis-parse the bracket string as a scalar.
doc = "---\ntype: table\ntags: [pii, customers]\n---\nbody\n"
# An inline flow collection this parser cannot read without guessing ->
# reject, don't silently mis-parse the bracket string as a scalar. The
# QUOTED elements are what makes this row stay red: the plain-scalar form
# (`tags: [pii, customers]`) is admitted as of 1.4.0 -- see the P1 block at
# the foot of this file -- while a quoted element would have to be stripped
# to be read, and this parser retains quotes.
doc = "---\ntype: table\ntags: ['pii', 'customers']\n---\nbody\n"
with pytest.raises(OKFFrontmatterError):
parse_frontmatter(doc)
@ -553,7 +557,10 @@ _V02_REJECTED = [
# that reason. SPEC's own §5.1 keys parse; see the G30 block at the foot.
("sources (block list, off-allowlist keys)",
"sources:\n - uri: https://e.com/a\n kind: doc\n"),
("flow sequence", "tags: [a, b, c]\n"),
# The plain-scalar flow sequence is admitted as of 1.4.0 (P1); this row now
# measures the QUOTED element, which stays refused for the same reason it
# always did -- reading it would mean stripping a quote this parser retains.
("flow sequence, quoted elements", "tags: ['a', 'b']\n"),
("flow mapping", "executor: {resource: skills/run.md}\n"),
]
@ -572,6 +579,8 @@ _V02_ADMITTED = [
("status/stale_after", "status: active\nstale_after: 2026-12-01\n"),
("verified bool", "verified: true\n"),
("block sequence of scalars", "tags:\n - alpha\n - beta\n"),
# SPEC §4.1's own skeleton for `tags`, admitted as of 1.4.0 (P1).
("flow sequence of scalars", "tags: [alpha, beta]\n"),
]
@ -726,7 +735,9 @@ def test_the_block_list_rejects_on_the_key_set_not_on_arity():
# G30 fix, not a weakening of this property. Their replacements are the same
# carriers with a key off the allowlist, so the row still measures what it
# says: the shape is admitted, the key set is not.
"generated: { by: x, tool: y }\n", "sources: [{ id: a, uri: u }]\n", "tags: [a, b]\n",
# The `tags` row moved the same way in 1.4.0: `[a, b]` now PARSES (P1), so
# the row carries the quoted form, which is still a T2 rejection.
"generated: { by: x, tool: y }\n", "sources: [{ id: a, uri: u }]\n", "tags: ['a', 'b']\n",
"generated:\n by: x\n", "generated.by: x\n",
"sources:\n - id: a\n uri: file://x\n",
"executor: [{ resource: skills/run.md }]\n",
@ -1054,21 +1065,142 @@ def test_a_block_list_may_not_mix_scalars_and_mappings(cid, fm):
@pytest.mark.parametrize("cid,fm", [
("flow sequence of scalars", "tags: [a, b, c]\n"),
("empty flow sequence", "sources: []\n"),
("flow sequence, unclosed", "sources: [{ id: a }\n"),
("flow sequence, trailing junk", "sources: [{ id: a }] x\n"),
("flow sequence, mixed", "sources: [{ id: a }, plain]\n"),
("flow sequence, nested sequence", "sources: [[ id ]]\n"),
])
def test_the_flow_sequence_admits_flow_mappings_only(cid, fm):
# The sequence carrier is opened for the mapping form and nothing else. A
# flow sequence of plain scalars stays refused -- it is a separate shape with
# its own quoting and splitting problems, and no measured consumer needs it.
def test_the_flow_sequence_refuses_a_carrier_it_cannot_read(cid, fm):
# What no element shape rescues: nothing at all, an unclosed mapping, junk
# between elements, and a sequence inside a sequence. The plain-scalar
# element admitted in 1.4.0 (P1) is a THIRD shape, not a loosening of these
# -- each row here still raises through it.
with pytest.raises(OKFFrontmatterError):
parse_frontmatter(f"---\ntype: table\n{fm}---\n\nbody\n")
# --- P1: the flow sequence of plain scalars (operator decision, 2026-09-08) --
# `tags: [a, b, c]` is the ONE form SPEC.md §4.1's own skeleton writes out for
# `tags`, and the only candidate measured to move the upstream corpus at all:
# 0/53 -> 6/53 against the pinned reference bundles (`_okf-upstream` @ 3fcbb9f,
# denominator 53). The two neighbours measured 0/53 EACH and are deliberately
# NOT built -- P2 (a flush-left block sequence) and P3 (a folded plain scalar)
# would cost parser surface and buy no measured conformance on this corpus;
# see docs/2026-09-07-limitations-44-maaling.md §§7-8.
#
# It does not spend the depth-1 rule: the elements are leaves, the same depth
# the block list of scalars already carries. The binding constraint on the
# corpus is elsewhere and stays refused -- 44/53 stop on `generated` written as
# a top-level block mapping, which is a security decision, not this one.
@pytest.mark.parametrize("cid,fm,expected", [
("SPEC §4.1 skeleton", "tags: [finance, revenue, headline-metric]\n",
["finance", "revenue", "headline-metric"]),
("two elements", "tags: [a, b]\n", ["a", "b"]),
("one element", "tags: [solo]\n", ["solo"]),
("uneven spacing", "tags: [ a ,b ]\n", ["a", "b"]),
("one trailing comma, as the flow mapping already allows", "tags: [a, b,]\n", ["a", "b"]),
])
def test_a_flow_sequence_of_plain_scalars_parses(cid, fm, expected):
# The red test for P1: `[a, b]` must come back as a real list of strings,
# never a degraded string (the 1.1.0 defect) and never a refusal.
assert parse_frontmatter(f"---\ntype: table\n{fm}---\n\nbody\n")[0]["tags"] == expected
def test_the_two_scalar_sequence_carriers_parse_to_the_same_value():
flow = parse_frontmatter("---\ntype: t\ntags: [a, b]\n---\n\nb\n")[0]
block = parse_frontmatter("---\ntype: t\ntags:\n - a\n - b\n---\n\nb\n")[0]
assert flow["tags"] == block["tags"] == ["a", "b"]
@pytest.mark.parametrize("cid,fm", [
# Quoting is the failure mode this shape is refused for elsewhere: the
# parser retains quotes rather than stripping them, so admitting a quoted
# element would hand back a value YAML reads differently.
("single-quoted element", "tags: ['a']\n"),
("double-quoted element", 'tags: ["a", "b"]\n'),
# Comma splitting is character-level, so anything that would need YAML
# semantics to split correctly is refused rather than guessed at.
("colon inside an element", "tags: [a: b]\n"),
("comment indicator", "tags: [a #b]\n"),
("brace inside an element", "tags: [a{b}]\n"),
("bracket inside an element", "tags: [a[b]]\n"),
# Depth: a sequence inside a sequence opens a second level and is refused
# on the same character rule, with no YAML semantics involved.
("nested flow sequence", "tags: [[a]]\n"),
("empty element", "tags: [a, , b]\n"),
("leading empty element", "tags: [, a]\n"),
("empty sequence", "tags: []\n"),
("unterminated sequence", "tags: [a, b\n"),
# The unchanged scalar indicators still apply to an element, exactly as they
# do to a block-list item: an anchor or an alias is not a plain scalar.
("anchor element", "tags: [&anchor]\n"),
("alias element", "tags: [*alias]\n"),
("explicit tag element", "tags: [!!python/object]\n"),
])
def test_a_flow_sequence_scalar_element_must_be_a_plain_scalar(cid, fm):
with pytest.raises(OKFFrontmatterError):
parse_frontmatter(f"---\ntype: table\n{fm}---\n\nbody\n")
@pytest.mark.parametrize("cid,fm", [
("scalar then mapping", "tags: [a, {b: c}]\n"),
("mapping then scalar", "sources: [{ id: a }, plain]\n"),
])
def test_a_flow_sequence_may_not_mix_scalars_and_mappings(cid, fm):
# Same rule, same reason as the block list: a consumer iterating the value
# and reading `entry.get("id")` crashes on the first str. The mixing verdict
# is reached BEFORE the element is parsed, so it is what the caller sees --
# not an allowlist complaint about a key that was never the problem.
with pytest.raises(OKFFrontmatterError) as exc:
parse_frontmatter(f"---\ntype: table\n{fm}---\n\nbody\n")
assert "mix" in str(exc.value)
@pytest.mark.parametrize("cid,fm,expected", [
("one entry", "sources: [{ id: x }]\n", [{"id": "x"}]),
("two entries", "sources: [{ id: a, title: A }, { id: b, title: B }]\n",
[{"id": "a", "title": "A"}, {"id": "b", "title": "B"}]),
])
def test_the_g30_flow_mapping_carrier_is_unchanged_by_the_scalar_element(cid, fm, expected):
# The no-regression pin. P1 adds a branch to the same function that carries
# `sources`; the mapping element must parse exactly as it did in 1.3.0.
assert parse_frontmatter(f"---\ntype: table\n{fm}---\n\nbody\n")[0]["sources"] == expected
# The self-safety row for P1 (OWASP LLM10). The predicate is character-level
# with no regex, so there is no backtracking engine to blow up -- but "no regex"
# is an argument, and the sweep in docs/redos-sweep.py cannot check it, because
# it collects compiled patterns and this predicate compiles none. So it is
# measured instead, on the same CPU clock every other bound here uses. Over four
# doublings (50k -> 800k, 2026-09-08) the exponent is 0.86-0.99 in the element
# LENGTH and 0.97-1.05 in the element COUNT: linear in both, 0.66s at 800_000
# elements and 0.03s at an 800_000-character element. The refusal path is the
# same shape -- a forbidden character at the very end of an 800_000-character
# element is found in 0.04s -- because the element is scanned once per rule, not
# rescanned per start position.
_FLOW_SCALAR_PERF_N = 400_000
def test_a_long_flow_sequence_of_scalars_stays_bounded():
many = "---\ntype: t\ntags: [" + ", ".join(["ab"] * _FLOW_SCALAR_PERF_N) + "]\n---\nbody\n"
assert scan_seconds(parse_frontmatter, many) < 2.0
one_long = "---\ntype: t\ntags: [" + "a" * _FLOW_SCALAR_PERF_N + "]\n---\nbody\n"
assert scan_seconds(parse_frontmatter, one_long) < 2.0
def test_a_long_flow_sequence_element_is_refused_without_a_rescan():
# The refusal is the half an attacker controls: a forbidden character parked
# at the END of a long element is the worst case for any per-start rescan.
doc = "---\ntype: t\ntags: [" + "a" * _FLOW_SCALAR_PERF_N + ":]\n---\nbody\n"
def refuse(payload):
with pytest.raises(OKFFrontmatterError):
parse_frontmatter(payload)
assert scan_seconds(refuse, doc) < 2.0
def test_injection_in_a_sources_entry_leaf_is_caught_by_the_scan():
# T1 over the new shape: every leaf of every entry reaches scan_output.
doc = f"---\ntype: table\nsources: [{{ id: a, title: {_INJECTION} }}]\n---\nclean\n"