From 16dee4745749f72c9acd5ab5cf9619adde1d7450 Mon Sep 17 00:00:00 2001 From: David de Boer Date: Mon, 17 Aug 2026 16:31:29 +0200 Subject: [PATCH 1/3] docs(adr-19): say which pairs idOnly replaces, in this package's own terms Two problems, one cause: the ADR reasoned about the deployment that motivated it rather than about the shape of the case, in a package that names neither a domain nor an engine. The factual one first. It listed five properties as pairs `idOnly` removes the reason for, but `idOnly` surfaces a bare `IRI`, and three of those hold **locators** - a content URL, a thumbnail, a landing page. Nothing selects on them, they carry no filter or facet, and typing them `IRI` would assert a selection key that does not exist. They keep the internal-reader-plus-keyword pair, which is still the only declaration yielding a plain `String` over an IRI-valued path. A reader following the ADR as written would have retyped its URLs as identity. The register, second. The narrowing caveat argued from one profile's co-typing of a specific class, which a reader of this package cannot check and does not need: the general statement is that a declaration names the target whose collection resolves its labels, which need not be the widest class the data admits. The counted claim about that deployment's declarations is gone with it - it was wrong as well as out of place. --- ...type-a-filter-by-what-its-field-keys-on.md | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/docs/decisions/0019-type-a-filter-by-what-its-field-keys-on.md b/docs/decisions/0019-type-a-filter-by-what-its-field-keys-on.md index 2fee6e97..be5f9fc0 100644 --- a/docs/decisions/0019-type-a-filter-by-what-its-field-keys-on.md +++ b/docs/decisions/0019-type-a-filter-by-what-its-field-keys-on.md @@ -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 declares **one** `ref.typeName` and one +`labelSource` – no list form, since the field emits one reference type and +resolves labels from 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 types and label sources +for every such field, 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 From 87e297e4e72ba4739e752cebbbafc62ab106e9a3 Mon Sep 17 00:00:00 2001 From: David de Boer Date: Wed, 19 Aug 2026 20:58:40 +0200 Subject: [PATCH 2/3] docs(adr): renumber this ADR out of its collision with the joins ADR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - two ADRs shipped as 0019; the joins one is linked from published package JSDoc by absolute github.com/…/main URLs, so this one moves instead - 0021 rather than 0020, which is taken; update the heading and the inbound links in ADR 4 and ADR 18 - retarget the two bare "ADR 19 sends" comments in the projection that meant this ADR, which the collision had made ambiguous --- docs/decisions/0004-search-api-graphql-surface.md | 2 +- .../0018-filter-across-several-fields-with-one-clause.md | 2 +- ...ys-on.md => 0021-type-a-filter-by-what-its-field-keys-on.md} | 2 +- packages/search/src/project.ts | 2 +- packages/search/test/project.test.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename docs/decisions/{0019-type-a-filter-by-what-its-field-keys-on.md => 0021-type-a-filter-by-what-its-field-keys-on.md} (99%) 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 99% 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 be5f9fc0..c44de81d 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 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', From 3b9c64cac604d741c9fc24e303ddfd8f981b174f Mon Sep 17 00:00:00 2001 From: David de Boer Date: Wed, 19 Aug 2026 20:58:51 +0200 Subject: [PATCH 3/3] docs(adr-21): state the refined limit in the strategy names that now exist - the narrowing caveat named a ref.typeName-plus-labelSource pairing that no longer exists: a lookup declares ref.target, an idOnly an optional ref.typeName, and labelSource is now idOnly's alone - the invariant it rests on survives the rename and is easier to check: one declared target per reference, still no list form - rename the three labelOnly mentions to lookup, so the ADR reads against the package a reader has --- ...type-a-filter-by-what-its-field-keys-on.md | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/decisions/0021-type-a-filter-by-what-its-field-keys-on.md b/docs/decisions/0021-type-a-filter-by-what-its-field-keys-on.md index c44de81d..aa57b0d3 100644 --- a/docs/decisions/0021-type-a-filter-by-what-its-field-keys-on.md +++ b/docs/decisions/0021-type-a-filter-by-what-its-field-keys-on.md @@ -43,15 +43,15 @@ 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 declares **one** `ref.typeName` and one -`labelSource` – no list form, since the field emits one reference type and -resolves labels from 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 types and label sources -for every such field, to answer what coarse already answers. Refinement is a +**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 @@ -140,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 @@ -159,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. @@ -170,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