From 5d52a4c4a1ec1ec7fb29a49e7cbd97c592cb8c98 Mon Sep 17 00:00:00 2001 From: Scotty Pate Date: Thu, 15 Oct 2026 03:01:17 -0500 Subject: [PATCH] Accept spaces in ?terms=/--terms and match space/_/- insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A caller relaying a person's own words asks for `white paper`, not `white_paper`; rewriting the request client-side would search for a token the person never said. The /context door previously 400'd any `terms` token with a space (ADR 0017 §3 grammar). - serve: `terms` tokens may contain interior spaces and are trimmed; other out-of-grammar characters still 400. - context::term_key: the lookup key — lowercased, runs of whitespace/ `_`/`-` folded to one `_` — used by resolve_terms, narrow_terms and resolve_semantic_matches, so `white paper`, `White-Paper` and `white_paper` all hit a `white_paper` term, alias or Ossie synonym. - is_addressable_token admits interior spaces, so spaced Ossie names and synonyms join the index; `datamk sync`'s warning reuses it. - is_model_token keeps `?model=`'s stricter no-space grammar for the OpenAPI enum, so the enum never offers a value that 400s. - definitions uniqueness is checked on term_key, so two names that would be unreachable from each other can't both be declared. - ADR 0017 §3 / ADR 0018 §7 amended. Co-Authored-By: Claude Fable 5.1 --- docs/adr/0017-definitions.md | 19 +++-- docs/adr/0018-ossie-ingest.md | 2 +- src/catalog/mod.rs | 20 +++-- src/cli.rs | 4 +- src/config/schema.rs | 9 ++- src/context.rs | 133 ++++++++++++++++++++++++++++------ src/serve/mod.rs | 67 ++++++++++++++--- src/serve/openapi.rs | 19 +++-- 8 files changed, 208 insertions(+), 65 deletions(-) diff --git a/docs/adr/0017-definitions.md b/docs/adr/0017-definitions.md index e13ae64..32be9d1 100644 --- a/docs/adr/0017-definitions.md +++ b/docs/adr/0017-definitions.md @@ -153,11 +153,20 @@ datamk context -f cell.yaml --terms net_revenue,active_customer Grammar, closed (ADR 0012 §7 / ADR 0013 §4): `/context` and `/context/` accept exactly `include` and `terms`; any other parameter -→ 400. `terms` is comma-separated; an empty value, an empty segment, a token -outside `[A-Za-z0-9_.-]`, or more than 64 tokens → 400. Repeated identical -tokens deduplicate. Matching is ASCII-case-insensitive over every term and -alias; a hit resolves to its canonical `term`, and two tokens resolving to -one term yield one entry. +→ 400. `terms` is comma-separated; each token is trimmed; an empty value, an +empty segment, a token outside `[A-Za-z0-9_.-]` plus space, or more than 64 +tokens → 400. Repeated identical tokens deduplicate. Matching is on a +normalized key — ASCII-lowercased, every run of whitespace/`_`/`-` folded to +one `_` — over every term and alias, so `white paper`, `White-Paper` and +`white_paper` all hit a `white_paper` term; a hit resolves to its canonical +`term`, and two tokens resolving to one term yield one entry. Uniqueness +(§1) is checked on the same key. + +*Amended 2026-10-15:* spaces were previously a 400. A caller — an agent +relaying a person's own words — asks for `white paper`, not `white_paper`; +rewriting the request on the client would have it search for a token the +person never said. The door now takes the words and the lookup folds the +separator, so the glossary's spelling is the glossary's concern alone. **An unknown term is not an error.** A robot asking for three terms and getting one wrong must keep the other two, and a 404 would push it back into diff --git a/docs/adr/0018-ossie-ingest.md b/docs/adr/0018-ossie-ingest.md index c19499c..4dfe568 100644 --- a/docs/adr/0018-ossie-ingest.md +++ b/docs/adr/0018-ossie-ingest.md @@ -206,7 +206,7 @@ Additive; `datamk_context` stays 4. Ossie content carries `from: ossie` | `/context` | `semantic_models[]`: name, description, file, dataset/bound/metric counts. Always present, `[]` without a source. | | `/context/` | `exports[].semantic[]`: every dataset bound to the route, with fields, `ai_context`, and per-field verification; the model's relationships touching it (other side named by route or `null`); metrics whose datasets are all bound to this route, in full; other metrics naming the dataset as `{name, model}` pointers. | | `/context?model=` | one semantic model in full (`semantic_model` block), composable with `terms`. | -| `/context?terms=…` | Ossie dataset, field and metric names plus `ai_context.synonyms` join the lookup index beside `definitions:`; hits land in `semantic_matches[]` `{token, kind, model, dataset, field, description}`. Synonyms outside `[A-Za-z0-9_.-]`/64 chars are kept in the document and warned at `sync` as not addressable. Collisions across models return every hit, each naming its model. | +| `/context?terms=…` | Ossie dataset, field and metric names plus `ai_context.synonyms` join the lookup index beside `definitions:`; hits land in `semantic_matches[]` `{token, kind, model, dataset, field, description}`. Synonyms outside `[A-Za-z0-9_.-]`/64 chars (interior spaces allowed since 2026-10-15; matching folds space/`_`/`-`, ADR 0017 §3) are kept in the document and warned at `sync` as not addressable. Collisions across models return every hit, each naming its model. | `datamk context --model ` mirrors `?model=`. The top-level `semantic` block on the document carries `synced_at`, `content_sha256`, diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs index 81d66a2..03dbe67 100644 --- a/src/catalog/mod.rs +++ b/src/catalog/mod.rs @@ -399,10 +399,12 @@ fn sync_semantic( Ok(()) } -/// ADR 0018 §7: a synonym outside `[A-Za-z0-9_.-]{1,64}` stays in the -/// document (never rejected — datamake ingests, never rewrites, Ossie) but -/// cannot join the `terms=` lookup index; warned here so an author notices -/// at sync time rather than wondering why a lookup silently misses. +/// ADR 0018 §7: a synonym outside the addressable grammar +/// (`context::is_addressable_token`: `[A-Za-z0-9_.-]{1,64}`, interior +/// spaces allowed) stays in the document (never rejected — datamake +/// ingests, never rewrites, Ossie) but cannot join the `terms=` lookup +/// index; warned here so an author notices at sync time rather than +/// wondering why a lookup silently misses. fn warn_unaddressable_synonyms(doc: &crate::ossie::Document) { let mut bad: Vec = Vec::new(); let mut check = |ai: &Option| { @@ -410,11 +412,7 @@ fn warn_unaddressable_synonyms(doc: &crate::ossie::Document) { return; }; for s in &d.synonyms { - let ok = !s.is_empty() - && s.chars().count() <= 64 - && s.chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')); - if !ok { + if !crate::context::is_addressable_token(s) { bad.push(s.clone()); } } @@ -434,8 +432,8 @@ fn warn_unaddressable_synonyms(doc: &crate::ossie::Document) { if !bad.is_empty() { let shown: Vec<&str> = bad.iter().take(5).map(String::as_str).collect(); eprintln!( - "Warning: {} ai_context.synonyms entries are not addressable ([A-Za-z0-9_.-]{{1,64}}) \ - and will not join the `terms=` lookup index: {}{}", + "Warning: {} ai_context.synonyms entries are not addressable ([A-Za-z0-9_.-]{{1,64}}, \ + spaces allowed inside) and will not join the `terms=` lookup index: {}{}", bad.len(), shown.join(", "), if bad.len() > shown.len() { ", …" } else { "" } diff --git a/src/cli.rs b/src/cli.rs index 4266482..597e5fa 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -281,7 +281,9 @@ pub struct ContextArgs { #[arg(long, value_name = "ROUTE")] pub export: Option, /// Narrow `definitions[]`/`docs[]` to a comma-separated list of terms - /// or aliases (ADR 0017), resolved against the whole cell and composing + /// or aliases (ADR 0017), matched case-, space-, `_`- and `-`- + /// insensitively (quote a multi-word term: --terms "white paper"), + /// resolved against the whole cell and composing /// with `--export` exactly as `?terms=` composes with /// `/context/`. Unlike the served door, an unknown term exits /// non-zero, naming the known ones — a file written by `--out` cannot diff --git a/src/config/schema.rs b/src/config/schema.rs index 80df787..5f104b7 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1574,7 +1574,9 @@ impl CellDef { /// ADR 0017 §1: shape validation for `definitions[]` — term/alias /// grammar, description length, cell-wide uniqueness (term and alias - /// names compared ASCII-case-insensitively across both sets), and the + /// names compared on `context::term_key` — case-, `_`- and + /// `-`-insensitive — across both sets, since that is the key `?terms=` + /// resolves on and two names sharing it would be unreachable), and the /// per-cell/per-term caps. Does not resolve `applies_to` against the /// interface (`validate_applies_to`'s job) — this runs before a /// `discover:` cell's interface exists. @@ -1612,11 +1614,12 @@ impl CellDef { validate_term_shape(alias, &d.term)?; } for name in std::iter::once(&d.term).chain(d.aliases.iter()) { - let key = name.to_ascii_lowercase(); + let key = crate::context::term_key(name); if let Some(prev) = seen.get(&key) { bail!( "definitions '{prev}' and '{}' both use the name '{name}' \ - (case-insensitive) — term and alias names must be unique cell-wide.", + (case-insensitive; `_` and `-` are the same separator) — term and \ + alias names must be unique cell-wide.", d.term ); } diff --git a/src/context.rs b/src/context.rs index b56cf6b..dea701a 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1444,17 +1444,54 @@ fn synonyms_of(ai: &Option) -> &[String] { } } -/// Whether a token is addressable via `?terms=`/`--terms` (ADR 0018 §7): -/// `[A-Za-z0-9_.-]{1,64}`. A synonym outside this grammar is kept in -/// `semantic_model`'s prose but never indexed here — `datamk sync` warns -/// about it at ingest time. +/// Whether a token is addressable via `?terms=`/`--terms` (ADR 0018 §7, +/// amended 2026-10): `[A-Za-z0-9_.-]{1,64}` with interior spaces allowed, +/// so a multi-word business term (`white paper`) is addressable as people +/// actually say it — no leading/trailing whitespace. A synonym outside +/// this grammar is kept in `semantic_model`'s prose but never indexed here +/// — `datamk sync` warns about it at ingest time. pub(crate) fn is_addressable_token(s: &str) -> bool { !s.is_empty() && s.len() <= 64 + && s.trim() == s + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-' | b' ')) +} + +/// Whether a name may enter `?model=`'s OpenAPI enum: the `model` door's +/// own grammar (`[A-Za-z0-9_.-]{1,128}`, `serve::validate_context_query`), +/// which — unlike `terms` — admits no spaces. A spaced model name is still +/// addressable through `?terms=` (`is_addressable_token`); it just can't +/// be the one `?model=` selects. +pub(crate) fn is_model_token(s: &str) -> bool { + !s.is_empty() + && s.len() <= 128 && s.bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-')) } +/// The key `?terms=`/`--terms` lookups match on (ADR 0017 §3, amended +/// 2026-10): ASCII-lowercased, trimmed, with every run of whitespace, `_` +/// or `-` folded to one `_`. `white paper`, `White-Paper` and `white_paper` +/// are one key, so a caller can ask in the words people use and still hit +/// the `white_paper` term the glossary spells with an underscore. +pub(crate) fn term_key(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut pending_sep = false; + for c in s.trim().chars() { + if c.is_whitespace() || c == '_' || c == '-' { + pending_sep = true; + continue; + } + if pending_sep { + out.push('_'); + pending_sep = false; + } + out.push(c.to_ascii_lowercase()); + } + out +} + /// Up to 8 entries of `known` nearest to `token` (case-insensitive, ASCII): /// prefix matches first, then substring matches, then same-first-3- /// characters matches, each tier only filling what the previous didn't — @@ -1648,8 +1685,9 @@ pub fn resolve_semantic_matches( ) -> Vec { let mut out = Vec::new(); for tok in tokens { + let key = term_key(tok); for entry in lookup { - if entry.token.eq_ignore_ascii_case(tok) { + if term_key(&entry.token) == key { out.push(SemanticMatch { token: tok.clone(), kind: entry.kind.clone(), @@ -2225,8 +2263,9 @@ impl ContextDocument { true } - /// `terms=` (ADR 0017 §3, extended by ADR 0018 §7): case-insensitive - /// lookup over `all_definitions`' terms/aliases AND every addressable + /// `terms=` (ADR 0017 §3, extended by ADR 0018 §7): `term_key` + /// (case-, space-, `_`-, `-`-insensitive) lookup over + /// `all_definitions`' terms/aliases AND every addressable /// Ossie dataset/field/metric name or synonym (`self.semantic_lookup`). /// A token may hit both; every Ossie hit is returned (collisions across /// models are all listed, each naming its model). Resolves against the @@ -2251,20 +2290,16 @@ impl ContextDocument { ) -> Vec { let mut index: IndexMap = IndexMap::new(); for d in all_definitions { - index - .entry(d.term.to_ascii_lowercase()) - .or_insert(d.term.as_str()); + index.entry(term_key(&d.term)).or_insert(d.term.as_str()); for alias in &d.aliases { - index - .entry(alias.to_ascii_lowercase()) - .or_insert(d.term.as_str()); + index.entry(term_key(alias)).or_insert(d.term.as_str()); } } let mut matched: Vec = Vec::new(); let mut missing: Vec = Vec::new(); let semantic_matches = resolve_semantic_matches(tokens, &self.semantic_lookup); for tok in tokens { - let definitions_hit = index.get(&tok.to_ascii_lowercase()); + let definitions_hit = index.get(&term_key(tok)); let ossie_hit = semantic_matches.iter().any(|m| &m.token == tok); if let Some(term) = definitions_hit { if !matched.iter().any(|t| t == term) { @@ -5302,6 +5337,43 @@ interface: ); } + /// ADR 0017 §3 (amended 2026-10): one key per business term however + /// a caller spells the word gap or case. + #[test] + fn term_key_folds_case_whitespace_underscore_and_hyphen() { + for s in [ + "white paper", + "White Paper", + "white_paper", + "WHITE-PAPER", + " white paper ", + "white_-_paper", + ] { + assert_eq!(term_key(s), "white_paper", "{s:?}"); + } + assert_eq!( + term_key("net.revenue"), + "net.revenue", + "`.` is not a separator" + ); + assert_eq!(term_key("nr"), "nr"); + assert_eq!(term_key(" "), ""); + assert!(is_addressable_token("white paper")); + assert!( + !is_addressable_token(" white paper"), + "no leading whitespace" + ); + assert!( + !is_addressable_token("white paper "), + "no trailing whitespace" + ); + assert!( + !is_addressable_token("white\tpaper"), + "only the ASCII space" + ); + assert!(!is_model_token("white paper"), "`?model=` admits no spaces"); + } + #[test] fn terms_resolve_ossie_dataset_field_metric_and_synonym_with_cross_model_collisions() { let def = sample_def_with_semantic(); @@ -5344,16 +5416,16 @@ interface: assert!(hits[0].field.is_none()); } - /// M6: a name outside the addressable token grammar - /// (`[A-Za-z0-9_.-]{1,64}`) is not indexed for `?terms=`/the OpenAPI - /// enums — it still exists in the document (`semantic_model`/ - /// `exports[].semantic`), just not in the lookup — mirroring what - /// already applied to synonyms before this fix reached dataset/field/ - /// metric/model names too. + /// M6, amended 2026-10: a name with interior spaces IS addressable via + /// `?terms=` (people search in their own words), so it is indexed; a + /// name outside the grammar for another reason (here, a `!`) is not, + /// but still exists in the document (`semantic_model`/ + /// `exports[].semantic`). `?model=`'s enum keeps the stricter + /// `is_model_token` grammar — exercised in `serve::openapi`'s tests. #[test] fn non_addressable_names_are_not_indexed_but_still_appear_in_the_document() { let mut ds = ossie_dataset("has space", "orders_daily", &[], vec![]); - ds.fields.push(ossie_field("also bad", "id")); + ds.fields.push(ossie_field("also bad!", "id")); let model = crate::ossie::SemanticModel { name: "model with space".to_string(), description: None, @@ -5392,10 +5464,23 @@ interface: }; let index = crate::ossie::bind::SemanticIndex::build(record, &[]); let lookup = semantic_lookup_entries(&index); - assert!(lookup.is_empty(), "{lookup:?}"); + let tokens: Vec<&str> = lookup.iter().map(|e| e.token.as_str()).collect(); + assert_eq!( + tokens, + vec!["model with space", "has space", "metric bad"], + "{lookup:?}" + ); + assert!(!is_model_token("model with space")); + + // A spaced token resolves however the caller spells the gap. + for spelling in ["has space", "Has_Space", "HAS-SPACE", "has space"] { + let hits = resolve_semantic_matches(&[spelling.to_string()], &lookup); + assert_eq!(hits.len(), 1, "{spelling}: {hits:?}"); + assert_eq!(hits[0].kind, "dataset"); + } + assert!(resolve_semantic_matches(&["also bad!".to_string()], &lookup).is_empty()); - // Still present in the model listing itself — not dropped, just - // not addressable. + // Still present in the model listing itself — not dropped. assert_eq!(index.models()[0].name, "model with space"); assert_eq!(index.models()[0].datasets[0].name, "has space"); } diff --git a/src/serve/mod.rs b/src/serve/mod.rs index d8a6d9d..3c0b16e 100644 --- a/src/serve/mod.rs +++ b/src/serve/mod.rs @@ -1380,7 +1380,9 @@ struct ContextQuery { /// model, view}` grammar (ADR 0013 §4, extended by ADR 0017 §3, ADR 0018 /// §7, and item 2): `include`'s value is a comma-separated list drawn from /// `INCLUDE_SECTIONS`; `terms`'s value is a comma-separated list of tokens -/// matching `[A-Za-z0-9_.-]`, capped at `MAX_TERMS`; `model`'s value is one +/// matching `[A-Za-z0-9_.-]` or space (a multi-word business term is +/// asked for in its own words — `white paper` — and matched on +/// `context::term_key`), each trimmed, capped at `MAX_TERMS`; `model`'s value is one /// token matching `[A-Za-z0-9_.-]{1,128}` — given more than once, a 400 (no /// comma-list semantics: a document names one model, not several); `view`'s /// value is one of `VIEW_SECTIONS`, also given at most once. Any other @@ -1421,19 +1423,20 @@ fn validate_context_query(pairs: &[(String, String)]) -> std::result::Result { for tok in v.split(',') { + let tok = tok.trim(); if tok.is_empty() { return Err( "`terms` must name at least one term or alias — a comma-separated \ - list matching [A-Za-z0-9_.-]" + list matching [A-Za-z0-9_.-] (spaces allowed inside a term)" .to_string(), ); } - if !tok - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-')) - { + if !tok.bytes().all(|b| { + b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-' | b' ') + }) { return Err(format!( - "`terms` token '{tok}' has characters outside [A-Za-z0-9_.-]" + "`terms` token '{tok}' has characters outside [A-Za-z0-9_.-] \ + (spaces allowed inside a term)" )); } terms_seen += 1; @@ -1497,7 +1500,7 @@ fn validate_context_query(pairs: &[(String, String)]) -> std::result::Result Vec { let mut index: HashMap = HashMap::new(); for d in &s.interface.definitions { index - .entry(d.term.to_ascii_lowercase()) + .entry(crate::context::term_key(&d.term)) .or_insert(d.term.as_str()); for alias in &d.aliases { index - .entry(alias.to_ascii_lowercase()) + .entry(crate::context::term_key(alias)) .or_insert(d.term.as_str()); } } let mut matched: Vec = Vec::new(); for tok in tokens { - if let Some(term) = index.get(&tok.to_ascii_lowercase()) { + if let Some(term) = index.get(&crate::context::term_key(tok)) { if !matched.iter().any(|t| t == term) { matched.push(term.to_string()); } @@ -4154,10 +4157,15 @@ pub(in crate::serve) mod smoke { assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert!(body.contains("must name at least one term"), "{body}"); - let (status, body) = get(&router, "/context?terms=bad%20term", None).await; + let (status, body) = get(&router, "/context?terms=bad%21term", None).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert!(body.contains("characters outside"), "{body}"); + // A whitespace-only token is an empty segment once trimmed. + let (status, body) = get(&router, "/context?terms=net_revenue,%20", None).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("must name at least one term"), "{body}"); + let many = (0..65) .map(|i| format!("t{i}")) .collect::>() @@ -4199,6 +4207,41 @@ pub(in crate::serve) mod smoke { }); } + /// ADR 0017 §3 (amended 2026-10): a multi-word term is asked for in + /// its own words. `net%20revenue`, `net+revenue` and `Net-Revenue` all + /// resolve to the `net_revenue` term; an unknown spaced token is a 200 + /// echoed (trimmed) in `missing_terms`, never a 400. + #[test] + fn terms_accept_spaces_and_match_space_underscore_hyphen_insensitively() { + let router = router_definitions(|_| {}); + rt().block_on(async { + for q in [ + "/context?terms=net%20revenue", + "/context?terms=net+revenue", + "/context?terms=Net-Revenue", + "/context?terms=%20net%20revenue%20", + ] { + let (status, body) = get(&router, q, None).await; + assert_eq!(status, StatusCode::OK, "{q}: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let terms: Vec<&str> = v["definitions"] + .as_array() + .unwrap() + .iter() + .map(|d| d["term"].as_str().unwrap()) + .collect(); + assert_eq!(terms, vec!["net_revenue"], "{q}: {body}"); + assert_eq!(v["missing_terms"], serde_json::json!([]), "{q}: {body}"); + } + + let (status, body) = get(&router, "/context?terms=white%20paper", None).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["definitions"].as_array().unwrap().len(), 0, "{body}"); + assert_eq!(v["missing_terms"], serde_json::json!(["white paper"])); + }); + } + /// ADR 0017 §3: `terms=` + `include=docs` inlines only the selected /// terms' pages — the cell page never appears, even though it exists. #[test] diff --git a/src/serve/openapi.rs b/src/serve/openapi.rs index a282dbe..6cd5ec2 100644 --- a/src/serve/openapi.rs +++ b/src/serve/openapi.rs @@ -60,13 +60,13 @@ pub fn generate_with_all( base_path: &str, ) -> Value { let terms_vocabulary = terms_vocabulary(definitions, semantic_lookup); - // M6: a model name outside the addressable token grammar - // ([A-Za-z0-9_.-]{1,64}) still lists in the document's `semantic_ - // models[]`, but must not enter this enum — it would 400 at the door - // the moment a caller tried the value the enum itself offered. + // M6: a model name outside `?model=`'s grammar ([A-Za-z0-9_.-]{1,128}, + // no spaces — stricter than `terms`) still lists in the document's + // `semantic_models[]`, but must not enter this enum — it would 400 at + // the door the moment a caller tried the value the enum itself offered. let model_vocabulary: Vec = semantic_models .iter() - .filter(|m| crate::context::is_addressable_token(&m.name)) + .filter(|m| crate::context::is_model_token(&m.name)) .map(|m| json!(m.name)) .collect(); let mut paths = Map::new(); @@ -181,9 +181,12 @@ fn context_path_item(terms_vocabulary: &[Value], model_vocabulary: &[Value]) -> "explode": false, "description": "Comma-separated term/alias list (ADR 0017) that narrows \ `definitions[]` and, under `include=docs`, `docs[]` to \ - those terms' `definition:` pages only. Matching is \ - case-insensitive; an unmatched token is echoed in \ - `missing_terms`, never a 400 or 404. On this door (never \ + those terms' `definition:` pages only. A token may \ + contain spaces — ask for a multi-word term in its own \ + words (`white paper`). Matching is case-insensitive and \ + treats space, `_` and `-` as one separator; an unmatched \ + token is echoed in `missing_terms`, never a 400 or 404. On \ + this door (never \ `/context/{route}`), also projects `exports[]` to the \ index shape (item 3) — the same projection `?view=index` \ applies — since asking for a handful of terms has no reason \