diff --git a/docs/decisions/0004-search-api-graphql-surface.md b/docs/decisions/0004-search-api-graphql-surface.md index 7e1cc3e8..8aa78788 100644 --- a/docs/decisions/0004-search-api-graphql-surface.md +++ b/docs/decisions/0004-search-api-graphql-surface.md @@ -19,7 +19,7 @@ so a source declaring `labelField: 'name'` is served as `{ id, name }`. A bucket’s `label` is unchanged: it is per-facet-field, and a per-type name would make the bucket type non-uniform. -Amended by [ADR 19](./0019-type-a-filter-by-what-its-field-keys-on.md): the +Amended by [ADR 21](./0021-type-a-filter-by-what-its-field-keys-on.md): the single `StringFilter` is replaced by filter inputs typed by what the field keys on (`KeywordFilter`, `IRIFilter`, `‹Target›Filter` over a new `IRI` scalar), `id` is filtered per type, and `idOnly` is implemented – so a reference surfaces diff --git a/docs/decisions/0018-filter-across-several-fields-with-one-clause.md b/docs/decisions/0018-filter-across-several-fields-with-one-clause.md index 970bf888..091658a6 100644 --- a/docs/decisions/0018-filter-across-several-fields-with-one-clause.md +++ b/docs/decisions/0018-filter-across-several-fields-with-one-clause.md @@ -6,7 +6,7 @@ Date: 2026-08-07 Accepted -Amended by [ADR 19](./0019-type-a-filter-by-what-its-field-keys-on.md), which +Amended by [ADR 21](./0021-type-a-filter-by-what-its-field-keys-on.md), which answers the question this ADR left to the client – _which_ fields belong in an `or` list – by typing each filter input by what its field keys on. The `StringFilter` in the sketches below no longer exists: `id` is filtered through a diff --git a/docs/decisions/0019-type-a-filter-by-what-its-field-keys-on.md b/docs/decisions/0021-type-a-filter-by-what-its-field-keys-on.md similarity index 78% rename from docs/decisions/0019-type-a-filter-by-what-its-field-keys-on.md rename to docs/decisions/0021-type-a-filter-by-what-its-field-keys-on.md index 2fee6e97..aa57b0d3 100644 --- a/docs/decisions/0019-type-a-filter-by-what-its-field-keys-on.md +++ b/docs/decisions/0021-type-a-filter-by-what-its-field-keys-on.md @@ -1,4 +1,4 @@ -# 19. Type a filter by what its field keys on +# 21. Type a filter by what its field keys on Date: 2026-08-13 @@ -38,32 +38,24 @@ what follows is only what the shape cost, and what it was chosen over. ### The coarse strategy is exhaustive; the refined one narrows -The two strategies do not carry the same guarantee, and a consumer has to know -which it is holding. - -**Coarse is complete.** Every field that keys on identity takes an `IRI`, so -selecting the criterion fields whose `in` element type is `IRI` returns all of -them. A consumer that must not miss a reference uses this one. - -**Refined is a narrowing, not a filter of equal standing.** It keys on -`ref.typeName`, which is the target a deployment **declares** – not necessarily -the only type the data admits there. Linked Open Limburg declares -`about: { typeName: 'Term' }` because SCHEMA-AP-NDE co-types referenced persons -and organizations as `DefinedTerm`, while the profile also admits a Person or an -Organization as the referent. So a consumer asking “which fields could hold this -person IRI?” gets `creator` through `PersonFilter` and does **not** get `about`. -What it returns is correct; what it returns is not everything. - -Declaring every admissible type per field would fix that and cost more than it -buys: a field has one `ref.typeName` because it emits one reference type and -resolves labels from one source, and multiplying the declaration multiplies both. -The narrowing is worth having as a precision tool, not as a completeness claim. - -Refinement is also unavailable where the target is not itself a root type: there -is no `‹Target›Where.id` to resolve the collection through, so coarse is the only -strategy. Both limits are stated in -[the package reference](../reference/search-api-graphql#finding-which-fields-accept-an-iri), -since a consumer meets them before it meets this ADR. +The two strategies do not carry the same guarantee. + +**Coarse is complete.** Every field keying on identity takes an `IRI`, so +selecting the criterion fields whose `in` element is `IRI` returns all of them. + +**Refined returns a subset.** A reference names **one** target – a `lookup`’s +`ref.target`, an `idOnly`’s `ref.typeName` – and there is no list form, since the +field emits one reference type and resolves through one collection. Where the +data admits several classes as referent, the declaration names one and nothing +records the rest. So asking “which fields could hold an IRI of type X?” returns +the fields that **declare** X, not those that may also **hold** one. + +Admitting a list of targets would multiply the emitted type and the collection +each such field resolves through, to answer what coarse already answers. Refinement is a +precision tool, not a completeness claim – and unavailable where the target is +not a root type, since there is no `‹Target›Where.id` to resolve through. Both +limits are in +[the package reference](../reference/search-api-graphql#finding-which-fields-accept-an-iri). ### `kind` is the discriminator, because `idOnly` now exists @@ -75,14 +67,27 @@ The reason those fields were `keyword` was never that they held literals. It was the **output shape**: `output` on a `reference` requires `ref`, and `ref` gave the `{ id, label }` object when what the deployment wanted was a flat list of IRIs. So a `reference` internal field laundered the IRIs and a `keyword` `derive` -surfaced them – seven such pairs in Linked Open Limburg, for `sameAs`, `license`, -`contentUrl`, `thumbnailUrl` and `landingPage`. +surfaced them – a pattern the first deployment to hit this carried over half a +dozen times. + +**`idOnly` replaces that pair only where the values are selection keys.** A +canonical vocabulary URI or a licence is one, and typing it `IRI` states a fact. +A **locator** is not – a content or thumbnail URL, a landing page, a manifest to +dereference. Nothing selects on those, they carry no filter or facet, and `IRI` +would assert a selection key that does not exist. They keep the +internal-reader-plus-`keyword` pair, still the only declaration yielding a plain +`String` over an IRI-valued path. + +So the split is per property, and the pattern that makes those pairs look alike +does not decide it – in that deployment they divide about evenly. A reader who +collapses every pair on sight retypes locators as identity, and a filter typed +`IRI` over a thumbnail URL is a promise nothing keeps. Implementing `idOnly` removes the reason instead of working around it. The marker -would have added a concept to the declaration language every deployment author -reads, to serve two fields, while leaving all seven pairs standing and `kind` a -non-discriminator. `idOnly` adds no concept – it was forward-declared in ADR 3 -and already documented as “the IRI” – and deletes fourteen declarations. +would have added a concept every deployment author reads, to serve two fields, +while leaving every pair standing and `kind` a non-discriminator. `idOnly` adds +no concept – it was forward-declared in ADR 3 and already documented as “the +IRI”. ### `IRI` validates in both directions @@ -135,7 +140,7 @@ referent projected as `_:b0`, which a facet would then offer and the filter refuse. `isAbsoluteIri` therefore lives in `@lde/search` and is applied by the projection as well, so the two cannot disagree. -What a `labelOnly`/`idOnly` reference stores is a **selection key**, and a +What a `lookup`/`idOnly` reference stores is a **selection key**, and a framing-minted label is not one: it recurs across documents and changes when unrelated triples do, so a bucket keyed on it neither groups what is equal nor separates what is not. An **inline** reference is untouched – it carries the @@ -154,7 +159,7 @@ exactly as before, which ADR 11 depends on. - A deployment declaring an IRI-valued field as `keyword` now mis-declares it visibly. The fix is `kind: 'reference'`, which no longer costs it the flat output shape. -- Blank-node referents disappear from `labelOnly`/`idOnly` reference fields. +- Blank-node referents disappear from `lookup`/`idOnly` reference fields. Where a deployment needs them addressable, the answer is upstream – skolemize in the graph, so the referent and every reference to it are rewritten together. @@ -165,7 +170,7 @@ so one fragment renders any reference field. Its premise is false: the reference types are not structurally identical. An inline type’s `id` is nullable _by design_ – a referent needs no identity (ADR 11) – and it carries the referent’s own `output` fields, which need not include a `label`; `idOnly` is not an object -at all. Narrowed to `labelOnly` types it becomes buildable and still does not +at all. Narrowed to `lookup` types it becomes buildable and still does not earn its place: a generic client is already introspecting to build the `or` list, so it can read `id`/`label` off each reference type structurally, and its queries are generated per deployment regardless. One shared fragment versus several diff --git a/packages/search/src/project.ts b/packages/search/src/project.ts index 552e0268..d17f1dd1 100644 --- a/packages/search/src/project.ts +++ b/packages/search/src/project.ts @@ -220,7 +220,7 @@ function applyField( if (field.derive !== undefined) { const derived = field.derive(document, context); // A derived `reference` is held to the same rule as a read one: what it - // stores is a selection key, and the surface types it `IRI`. ADR 19 sends + // stores is a selection key, and the surface types it `IRI`. ADR 21 sends // an IRI-valued `keyword` derive here by converting it to `reference`, so // this is the busiest way a non-IRI could otherwise reach the index. A // value that survives nothing leaves the field absent, exactly as a derive diff --git a/packages/search/test/project.test.ts b/packages/search/test/project.test.ts index 11f6ded1..444e1173 100644 --- a/packages/search/test/project.test.ts +++ b/packages/search/test/project.test.ts @@ -582,7 +582,7 @@ describe('projectDocument', () => { name: 'Dataset', class: DATASET, fields: [ - // ADR 19 sends IRI-valued keyword derives here; a returned non-IRI is + // ADR 21 sends IRI-valued keyword derives here; a returned non-IRI is // dropped, exactly as the graph path drops one. { name: 'derived',