feat(okf): sources becomes expressible, and the parent key is what admits resource
Order 20260902T150716Z from .claude -- a K5 blocker in the OKF programme.
`parse_frontmatter` rejected `sources` in every form the spec and its
producers actually use. Measured 02.09 by two consumers independently:
`sources: [{ id: a, resource: x }]` raised on the `[` indicator (one entry
as well as two), and the block sequence of block mappings -- SPEC.md 5.1's
OWN example -- raised "nested mappings are not supported". `resource` is
REQUIRED within a `sources` entry, so the whole provenance family was
unwritable and a bundle written the way the spec documents it was refused.
Measured against the spec before coding, not reasoned: 5.1's example block
is the canonical carrier for a REQUIRED field and 11.1 defines conformance
as parseable frontmatter, so refusing it refuses a conformant bundle. Both
carriers now parse to the same list of dicts.
The load-bearing change is not the carrier, it is WHO admits `resource`.
1.2.0 left it off the allowlist arguing the parser could not tell
`sources[].resource` (5.1, a citation) from `executor.resource` /
`attester.resource` (10, a pointer to code to be run -- the door-C route
closed in 1.1.0). That premise was false: the owning key is in scope at
every call site and was simply never threaded through. It is threaded now,
so the discrimination is structural, and door C stays shut through EVERY
carrier including the two this adds -- pinned by a new test that drives
`executor`/`attester` through all four.
Refusal stays the default elsewhere. A flow sequence of plain scalars
(`tags: [a, b]`) still raises: the sequence carrier is opened for the flow
mapping element and nothing else. A `sources` entry admits scalar leaves
only, so 5.1's optional PER-ENTRY `usage_window` is refused -- no nesting
past depth 1 is a security property and it was not spent here; registered
as a conformance gap rather than left as an oversight. A block list may not
mix scalars and mappings, because a consumer reading `entry.get("id")` over
one gets an AttributeError off the first str.
New residual registered: `sources[].resource` is scanned as text (T1) but
never URL-validated. T3's https allowlist cannot reach it without
over-blocking conformant bundles -- 5.1 permits bundle-relative paths and
scope descriptors, and the producers' own golden emits `resource: fixture`.
A consumer that dereferences it must call `validate_resource_url` itself.
Suite 834 -> 859 green. 25 new rows; four pre-existing rows changed because
this release changed the behaviour they pinned, two of them renamed since
their names asserted the old invariant (`exactly_one_route_to_a_mapping`,
`two_keys_per_item_is_where_the_block_list_hard_rejects`). Not "unchanged".
130/130 classes, 6/6 gaps hold, 44 -> 45 limitations, ReDoS 0/152 (the
sweep adds no evidence here -- this change adds no regex and the splitting
is linear). Six version surfaces bumped by hand, no sed. Re-measured alone
after the bump.
No exported surface changed; no detector behaviour and no calibration
changed.
This commit is contained in:
parent
0184df9ed9
commit
a965e8ac5b
10 changed files with 555 additions and 105 deletions
|
|
@ -548,7 +548,10 @@ _V02_REJECTED = [
|
|||
("generated (nested)", "generated:\n at: 2026-07-26T10:00:00Z\n"),
|
||||
("executor (nested)", "executor:\n resource: skills/run-on-bq.md\n"),
|
||||
("attester (nested)", "attester:\n resource: attesters/sql_equality.py\n"),
|
||||
("sources (block list of mappings)",
|
||||
# The carrier is admitted as of 1.3.0 (G30); this row now measures the KEY
|
||||
# SET - `uri`/`kind` are producer-invented, not SPEC §5.1 - and stays red for
|
||||
# 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"),
|
||||
("flow mapping", "executor: {resource: skills/run.md}\n"),
|
||||
|
|
@ -586,6 +589,8 @@ def test_v02_flat_frontmatter_still_parses(cid, fm):
|
|||
|
||||
_DEGRADED_TO_STRING = [
|
||||
# (id, frontmatter, what PyYAML 6.0.3 makes of it)
|
||||
# Still red after G30 opened the block-mapping carrier: `uri` is not a §5.1
|
||||
# key, so the item is refused by the allowlist instead of by the grammar.
|
||||
("one key per item", "sources:\n - uri: https://e.com/a\n", "[{'uri': ...}]"),
|
||||
("item, trailing colon", "sources:\n - uri:\n", "[{'uri': None}]"),
|
||||
("inline double colon", "attester: resource: attesters/sql_equality.py\n", "parse error"),
|
||||
|
|
@ -653,13 +658,16 @@ def test_pointer_in_a_degraded_mapping_no_longer_reaches_the_consumer_tree(cid,
|
|||
assert result.disposition is Disposition.FAIL_SECURE, "hole reopened — see LIMITATIONS.md"
|
||||
|
||||
|
||||
def test_exactly_one_route_to_a_mapping_is_expressible():
|
||||
# Was: ALL FOUR routes failed, each on its own rule, so the mapping *class* had
|
||||
# no expressible form (and v0.2's `generated` could not be written at all). G3
|
||||
# opens exactly ONE of them - the allowlisted flow form - and the other three
|
||||
# still fail, each on its own rule. That the openable route is the one whose
|
||||
# every key the allowlist inspects is the whole design: block, dotted and inline
|
||||
# give the allowlist nothing to inspect, so they stay shut.
|
||||
def test_the_expressible_mapping_routes_are_the_ones_the_allowlist_inspects():
|
||||
# Was `test_exactly_one_route_to_a_mapping_is_expressible` (1.2.0), and before
|
||||
# that ALL FOUR routes failed so the mapping *class* had no expressible form.
|
||||
# There are four expressible carriers as of 1.3.0 - flow mapping as a value,
|
||||
# flow mapping as a list item, flow sequence of flow mappings, block sequence
|
||||
# of block mappings - and the criterion that admits them is unchanged: each
|
||||
# hands the allowlist every key. The routes below stay shut for the same
|
||||
# reason, each on its own rule: a top-level block MAPPING (not a sequence),
|
||||
# a dotted key, and an inline second colon give the allowlist nothing to
|
||||
# inspect.
|
||||
assert parse_frontmatter("---\nid: x\ngenerated: { by: x, at: y }\n---\n\nbody\n")[0][
|
||||
"generated"] == {"by": "x", "at": "y"}
|
||||
|
||||
|
|
@ -695,19 +703,33 @@ def test_block_lists_admitted_by_item_shape(cid, fm, expected):
|
|||
assert parse_frontmatter(f"---\nid: x\n{fm}---\n\nbody\n")[0][key] == expected
|
||||
|
||||
|
||||
def test_two_keys_per_item_is_where_the_block_list_hard_rejects():
|
||||
def test_the_block_list_rejects_on_the_key_set_not_on_arity():
|
||||
# Was `test_two_keys_per_item_is_where_the_block_list_hard_rejects`: a
|
||||
# two-key item was refused because the block mapping had no expressible form
|
||||
# at all. G30 gives it one (SPEC.md §5.1's own carrier), so arity is no
|
||||
# longer the boundary - the key set is. The same two-key item parses when its
|
||||
# keys are §5.1's, and still raises when one of them is not.
|
||||
parsed = parse_frontmatter(
|
||||
"---\nid: x\nsources:\n - id: a\n resource: file://x\n---\n\nbody\n"
|
||||
)[0]
|
||||
assert parsed["sources"] == [{"id": "a", "resource": "file://x"}]
|
||||
with pytest.raises(OKFFrontmatterError):
|
||||
parse_frontmatter(
|
||||
"---\nid: x\nsources:\n - id: a\n resource: file://x\n---\n\nbody\n"
|
||||
"---\nid: x\nsources:\n - id: a\n uri: file://x\n---\n\nbody\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fm", [
|
||||
# The flow row carries a key OFF the G3 allowlist: the shape is admitted, the
|
||||
# key is not, so this stays a T2 rejection and the door A/B half still holds.
|
||||
"generated: { by: x, tool: y }\n", "sources: [{ id: a }]\n", "tags: [a, b]\n",
|
||||
# The two `sources` rows that lived here until 1.3.0 now PARSE - that is the
|
||||
# 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",
|
||||
"generated:\n by: x\n", "generated.by: x\n",
|
||||
"sources:\n - id: a\n resource: file://x\n",
|
||||
"sources:\n - id: a\n uri: file://x\n",
|
||||
"executor: [{ resource: skills/run.md }]\n",
|
||||
])
|
||||
def test_t2_constrains_import_not_emission(fm):
|
||||
# T2 runs on door C only. The same frontmatter that FAIL_SECUREs through
|
||||
|
|
@ -903,3 +925,170 @@ def test_a_conformant_v02_trust_layer_now_reaches_the_gate():
|
|||
result = import_bundle({"tables/users.md": doc})
|
||||
assert result.disposition is Disposition.WARN
|
||||
assert result.concepts[0].error is None
|
||||
|
||||
|
||||
# --- G30: the `sources` provenance layer becomes reachable (2026-09-02) ------
|
||||
# Door 2. G3 gave the mapping *class* one expressible form but left `sources`
|
||||
# unreachable: SPEC.md §5.1 writes an entry as a MAPPING carrying a REQUIRED
|
||||
# `resource`, so neither of the two carriers the spec and the producers actually
|
||||
# use could parse. Measured 02.09 by two consumers independently -- a flow
|
||||
# sequence of flow mappings raised on the `[` indicator, a block sequence of
|
||||
# block mappings raised "nested mappings are not supported".
|
||||
#
|
||||
# Why `resource` is admissible now when 1.2.0 argued it was not: the old
|
||||
# argument was that the parser could not tell `sources[].resource` (§5.1, a
|
||||
# citation) from `executor.resource` (§10, a code pointer). That premise was
|
||||
# false -- the owning key is in scope at every call site, it was simply never
|
||||
# threaded through. `resource` is allowlisted for `sources` entries ONLY, so
|
||||
# the door-C routes above stay shut on the same input.
|
||||
|
||||
_SPEC_51_BLOCK = (
|
||||
"sources:\n"
|
||||
" - id: ga4-schema\n"
|
||||
" resource: https://developers.google.com/analytics/bigquery/export-schema\n"
|
||||
" title: GA4 BigQuery Export schema\n"
|
||||
" author: team:ga4-docs\n"
|
||||
" usage_count: 5000\n"
|
||||
" last_modified: 2026-05-30T00:00:00Z\n"
|
||||
)
|
||||
|
||||
|
||||
def test_spec_sources_block_sequence_of_block_mappings_parses():
|
||||
# SPEC.md §5.1's own example block, verbatim. It is the spec's canonical
|
||||
# carrier for a REQUIRED field, so §11.1 ("parseable YAML frontmatter") makes
|
||||
# a bundle written this way conformant -- refusing it refuses a conformant
|
||||
# bundle, which is the failure mode G3 was opened to end.
|
||||
fm, _ = parse_frontmatter(f"---\ntype: table\n{_SPEC_51_BLOCK}---\n\nbody\n")
|
||||
assert fm["sources"] == [{
|
||||
"id": "ga4-schema",
|
||||
"resource": "https://developers.google.com/analytics/bigquery/export-schema",
|
||||
"title": "GA4 BigQuery Export schema",
|
||||
"author": "team:ga4-docs",
|
||||
"usage_count": "5000",
|
||||
"last_modified": "2026-05-30T00:00:00Z",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cid,fm,expected", [
|
||||
("one entry",
|
||||
"sources: [{ id: golden-v0-2-sales, resource: fixture }]\n",
|
||||
[{"id": "golden-v0-2-sales", "resource": "fixture"}]),
|
||||
("two entries",
|
||||
"sources: [{ id: a, resource: https://e.com/a }, { id: b, resource: https://e.com/b }]\n",
|
||||
[{"id": "a", "resource": "https://e.com/a"},
|
||||
{"id": "b", "resource": "https://e.com/b"}]),
|
||||
])
|
||||
def test_sources_flow_sequence_of_flow_mappings_parses(cid, fm, expected):
|
||||
# The form the producer emits today (llm-ingestion-okf's golden
|
||||
# expected-bundle/ingest-sales.md, measured 02.09). One entry raised too, so
|
||||
# this is not an arity bug: the `[` indicator refused the carrier outright.
|
||||
assert parse_frontmatter(f"---\ntype: table\n{fm}---\n\nbody\n")[0]["sources"] == expected
|
||||
|
||||
|
||||
def test_the_two_sources_carriers_parse_to_the_same_value():
|
||||
block = parse_frontmatter(
|
||||
"---\ntype: t\nsources:\n - id: a\n resource: https://e.com/a\n---\n\nb\n")[0]
|
||||
flow = parse_frontmatter(
|
||||
"---\ntype: t\nsources: [{ id: a, resource: https://e.com/a }]\n---\n\nb\n")[0]
|
||||
assert block["sources"] == flow["sources"] == [{"id": "a", "resource": "https://e.com/a"}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cid,fm", [
|
||||
("unknown key, flow", "sources: [{ id: a, uri: https://e.com/a }]\n"),
|
||||
("unknown key, block", "sources:\n - id: a\n uri: https://e.com/a\n"),
|
||||
("unknown key, flow value", "sources: { id: a, kind: doc }\n"),
|
||||
])
|
||||
def test_an_unknown_key_in_a_sources_entry_is_still_rejected(cid, fm):
|
||||
# The negative control. The carrier is admitted; the key set is not. A
|
||||
# producer-invented key gets no free ride on the new shape.
|
||||
with pytest.raises(OKFFrontmatterError) as exc:
|
||||
parse_frontmatter(f"---\ntype: table\n{fm}---\n\nbody\n")
|
||||
assert "allowlist" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cid,fm", [
|
||||
("executor, block sequence", "executor:\n - resource: skills/run-on-bq.md\n"),
|
||||
("executor, flow sequence", "executor: [{ resource: skills/run-on-bq.md }]\n"),
|
||||
("attester, flow sequence", "attester: [{ resource: attesters/sql_equality.py }]\n"),
|
||||
("attester, block sequence", "attester:\n - resource: attesters/sql_equality.py\n"),
|
||||
])
|
||||
def test_resource_is_allowlisted_for_sources_entries_only(cid, fm):
|
||||
# The whole reason `resource` can be admitted at all: the owning key decides.
|
||||
# §10's `executor.resource` / `attester.resource` name run instructions and
|
||||
# code -- door C -- and stay refused through EVERY carrier, including the two
|
||||
# this change opens for `sources`.
|
||||
doc = f"---\nid: x\ntype: Attested Computation\n{fm}---\n\nbody\n"
|
||||
with pytest.raises(OKFFrontmatterError) as exc:
|
||||
parse_frontmatter(doc)
|
||||
assert "allowlist" in str(exc.value)
|
||||
assert import_bundle({"computations/x.md": doc}).disposition is Disposition.FAIL_SECURE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cid,fm", [
|
||||
# SPEC §5.1: "A single entry MAY carry its own `usage_window`". That is a
|
||||
# mapping inside a mapping -- depth 2 -- and stays refused: no nesting deeper
|
||||
# than one is a security property this change does not spend. Registered as a
|
||||
# conformance gap in docs/LIMITATIONS.md, not as an oversight.
|
||||
("per-entry usage_window, flow",
|
||||
"sources: [{ id: a, usage_window: { from: x, to: y } }]\n"),
|
||||
("per-entry usage_window, block",
|
||||
"sources:\n - id: a\n usage_window: { from: x, to: y }\n"),
|
||||
# A sequence inside an entry is the same depth violation.
|
||||
("nested sequence", "sources: [{ id: a, title: [x, y] }]\n"),
|
||||
])
|
||||
def test_a_sources_entry_admits_scalar_leaves_only(cid, fm):
|
||||
with pytest.raises(OKFFrontmatterError):
|
||||
parse_frontmatter(f"---\ntype: table\n{fm}---\n\nbody\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cid,fm", [
|
||||
("scalar then mapping", "sources:\n - https://e.com/a\n - id: b\n title: B\n"),
|
||||
("mapping then scalar", "sources:\n - id: a\n title: A\n - https://e.com/b\n"),
|
||||
])
|
||||
def test_a_block_list_may_not_mix_scalars_and_mappings(cid, fm):
|
||||
# A consumer that reads `entry.get("id")` over the list crashes on the str.
|
||||
# One list, one item type -- refuse rather than hand back a mixed tree.
|
||||
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", [
|
||||
("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.
|
||||
with pytest.raises(OKFFrontmatterError):
|
||||
parse_frontmatter(f"---\ntype: table\n{fm}---\n\nbody\n")
|
||||
|
||||
|
||||
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"
|
||||
assert scan_concept(doc).found is True
|
||||
doc_block = f"---\ntype: table\nsources:\n - id: a\n title: {_INJECTION}\n---\nclean\n"
|
||||
assert scan_concept(doc_block).found is True
|
||||
|
||||
|
||||
def test_the_producer_golden_now_passes_the_gate():
|
||||
# llm-ingestion-okf's expected-bundle/ingest-sales.md, the K5 blocker.
|
||||
doc = (
|
||||
"---\n"
|
||||
"type: dataset\n"
|
||||
"title: Regional Sales\n"
|
||||
"source_system: golden-v0-2-sales\n"
|
||||
"ingested_at: 2026-07-16T12:00:00Z\n"
|
||||
"generated: { by: process:okf-ingest, at: 2026-07-16T12:00:00Z }\n"
|
||||
"sources: [{ id: golden-v0-2-sales, resource: fixture }]\n"
|
||||
"---\n\n| region | units |\n| --- | --- |\n| nord | 412 |\n"
|
||||
)
|
||||
result = import_bundle({"datasets/sales.md": doc})
|
||||
assert result.disposition is Disposition.WARN
|
||||
assert result.concepts[0].error is None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue