A Unified Per-Path Dataset Config Model¶
Date: 2026-06-29 Status: Accepted
Context¶
Five queued issues each want to attach information to a path within a dataset, and each is currently inclined to add its own top-level config key:
- #107 — override how a path dispatches (pin a content type, or force a
specific rule to claim it) — e.g. treat extensionless
northamericaas text, not binary. - #69 — declare or sniff CSV dialect (delimiter, quote, escape, BOM) for a path.
- #104 — declare a table's physical shape:
has_header: false,header_line/skip_lines, key-by-position. - #105 — name the nested record collection inside a document
(
records_path: "$.objects") so keying can reach it. - #106 — declare a structured-tree key attribute
(
key_attribute: identifier) for XML/USLM-style documents.
Today, core treats DatasetConfig.dataset as an opaque serde_json::Value
(binoc-core/src/config.rs) and passes it through untouched — rule #1, the
controller is type-ignorant. The stdlib deserializes it into a
DatasetSemanticsV1 whose only per-path facet so far is row keying:
dataset:
tables:
defaults: { row_identity: { columns: [...] } }
entries: [ { <path matcher>, row_identity: { columns: [...] } } ]
row_identity_for_paths (binoc-stdlib/src/correspondence/mod.rs:477) resolves
this with first-match-wins over a default — but only for paths that
is_tabular_path already classified as tabular by extension. So the bones of a
per-path override system exist; the risk is that five issues bolt five sibling
keys onto it, each with its own matcher syntax and merge rules, producing a
config surface where you cannot say "this one path is text and keyed" and where
the matcher behaves differently in each facet.
Decision¶
Generalize the existing tables.entries mechanism into one ordered list of
per-path entries, each a selector plus optional facets, owned and
interpreted entirely by the stdlib under the opaque core blob. There is one path
matcher, one precedence rule (first match wins, merged over defaults), and one
place to learn how binoc decides what to do with a path.
dataset:
defaults:
row_identity: { columns: [...] } # facet defaults, as today
paths:
- match: "**/SDN.CSV" # glob selector
content_type: text/csv # dispatch override (#107)
dialect: { delimiter: "," } # dialect facet (#69)
shape: { has_header: false } # shape facet (#104)
row_identity: { by_position: [1] } # key-by-position (#104)
- match: "**/northamerica"
content_type: text/plain # promote out of binary (#107)
- match: "**/legacy.dat"
rule: binoc.xml.parse # force a rule when no media fits (#107)
- match: "**/*.stix.json"
records_path: "$.objects" # nested-collection facet (#105)
row_identity: { columns: ["id"] } # keying reaches into it (#105)
- match: "**/usc/**/*.xml"
node_identity: { key_attribute: "identifier" } # tree-key facet (#106)
The selector is a glob (**/SDN.CSV, **/northamerica). A regex:-style
opt-in can be added later if a target needs anchoring a glob can't express; glob
is the no-surprise default and matches how most users think about paths.
The dispatch override (#107) is open, not a closed kind enum. It takes one of
two forms: content_type: pins a media type and lets normal declarative dispatch
(rule #5's media filter) run — so any rule, stdlib or plugin, that filters on that
media gets a fair claim; rule: forces a named rule to claim the node, the escape
hatch for when no media type disambiguates or two rules filter the same media.
text/csv/text/plain/binary are just common outcomes of stdlib's
projection_for(), not the schema — keeping the override a media/rule reference
(rather than a text|tabular|binary enum) is what lets it reach plugin formats
without a schema change. It is also the same mechanism as #107's content sniff:
sniffing infers a content type (disclosed, per the inference ADR), config
declares one (silent).
For #104's position-keying case, row_identity.by_position is configuration
sugar that resolves to the same canonical column-name form the tabular writer
already consumes. Positions are 1-based: by_position: [1] resolves to
columns: ["column_1"]. On a headerless table declared with
shape: { has_header: false }, the parser synthesizes the same positional
headers (column_1, column_2, ...), so by_position: [1] and
columns: ["column_1"] are the same key expressed two ways. Downstream
row-key maps stay name-keyed; position syntax does not add a second identity
mode to the IR or writer contract.
Note the keying facets sit beside this override, and none of them is a fourth
projection kind: row_identity keys a flat table, records_path + row_identity
keys a table nested in a document, and node_identity keys nodes in a
structured-document tree (#106). Tree-ness is a parser outcome (JSON, or XML via
binoc-xml, becomes a structured_document artifact) — config does not select
it, it only supplies the key. That is why there is no as: tree.
Resolution order is the load-bearing part:
- Selector match → first entry whose
matchmatches the logical path. - Dispatch override (
content_type:/rule:) feeds declarative dispatch — and runs before the tabular gate. This is the fix for the current ordering bug: todayrow_identity_for_pathsfilters tois_tabular_pathfirst, so config can only key paths the extension already called tabular. Under the new model,content_type: text/csvlets a path dispatch into the tabular parse rule (orrule:forces it), after which the resulting artifact's facets apply. - Kind selects which facets are meaningful.
dialect/shape/row_identityapply to tabular;records_path+row_identityapply to a document that contains a table;node_identityapplies to a structured-document tree. A facet that doesn't match the kind is a config error, not silently ignored (no-guess path, per AGENTS.md rule #5).
Defaults merge under entries facet-by-facet (as row_identity already does at
mod.rs:494-501), so an entry only states what it overrides.
This keeps the schema in stdlib (rule #1), reuses the existing precedence semantics, and means each of #69/#104/#105/#106/#107 becomes "add one facet to a known shape and teach the resolver one new branch," not "invent a top-level key."
Alternatives Considered¶
Flat sibling keys (dataset.tables, dataset.dialects, dataset.projections,
dataset.trees, each its own list). Rejected: five matchers that will drift in
syntax and precedence, duplicated selector logic, and no way to express a single
path that is both promoted to a kind and configured within that kind (e.g.
OFAC's SDN.CSV needs projection + dialect + shape + position-key together).
This is the path of least resistance for five parallel agents and exactly the
incoherence this ADR exists to prevent.
One selector list, but facets typed as separate parallel sub-lists keyed by the same selector string. Rejected: you still match the same path three times and must keep three lists in sync; the "one path, one rule" readability is lost.
Put the schema in core so every plugin shares it. Rejected: violates the type-ignorant controller (rule #1). Core carries the blob; the pack that owns the domain owns the schema. A third-party pack with its own facets deserializes its own slice, exactly as stdlib does.
Keep tables/trees as two typed top-level lists (tables and trees are
genuinely different artifact kinds, so two lists is arguably honest). This is the
closest rejected alternative and a reasonable fallback. Rejected in favor of one
list because the selector and precedence are what most need unifying, and a
single as:-discriminated entry makes "what kind is this path, and how is it
configured" answerable in one lookup. Open for discussion: if validation by kind
proves awkward, splitting paths into tables/trees while sharing the selector
+ precedence helpers is an acceptable retreat.
Open sub-decisions (flagged for review, not yet decided here)¶
- Whether the
node_identityfacet (#106's keyed-tree, the heaviest issue) lands in this round or is reserved — the schema slot is defined now so the model is forward-compatible either way, since it is just another keying facet alongsiderow_identity.