Skip to content

Commit cd9aae3

Browse files
authored
feat(standard): name works by abbreviation and translated title (#85) (#89)
A Work carried exactly one name. The registry browser built its filter key from `preferred_label` and `key` only, so a scholar who typed "NE", "EN", or "Nikomachische Ethik" found nothing. Add an optional `alternative_labels: string[]` to Work, published as `skos:altLabel`. The `skos` prefix was already declared, so no new namespace is needed. The field is identity-neutral. No label is a UUID seed input (ADR-0002), so a label change never moves an identifier. A test locks this. Entries are unique within one work, never repeat the preferred label, and an empty list is rejected. All three are enforced on the authored YAML and again on the published record, because the canonical schema is what the standard publishes. Record the decision and its four open questions in ADR-0007. Closes #85.
1 parent 8bcfdca commit cd9aae3

13 files changed

Lines changed: 400 additions & 3 deletions

File tree

‎api/openapi.yaml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,17 @@ components:
393393
key: { $ref: '#/components/schemas/FlatKey' }
394394
type: { type: string, enum: [Work] }
395395
preferred_label: { type: string, minLength: 1 }
396+
alternative_labels:
397+
type: array
398+
minItems: 1
399+
uniqueItems: true
400+
items: { type: string, minLength: 1 }
401+
description:
402+
Additional names for the work — abbreviations, translated
403+
titles, and established short forms. Search and display only.
404+
Identity-neutral, because no label is a UUID seed input, so a
405+
label change never moves an identifier. Unique within one work;
406+
two different works MAY share an alternative label.
396407
preferred_citation_system_key:
397408
allOf:
398409
- $ref: '#/components/schemas/FlatKey'
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# ADR-0007: Alternative labels on `Work` — one flat list, published as `skos:altLabel`
2+
3+
- **Status:** Accepted
4+
- **Date:** 2026-08-24
5+
- **Deciders:** @maehr
6+
- **Tags:** spec, data-model
7+
8+
## Context and problem statement
9+
10+
A `Work` carries exactly one name. `standard/schema/work.ts:22` declares `preferred_label` as a plain string, and `public/contexts/v1.jsonld:16` maps it to `skos:prefLabel`. Nothing else names a work. There is no `alt_label`, no `abbreviation`, no `alternateName`, and no language-tagged label anywhere in `standard/`, `scripts/`, `data/`, or the published context. `alternateOf` is not a label field: it holds IRIs of other entities, the compiler derives it from `MappingAssertion`s, and an author never writes it (ADR-0006).
11+
12+
The consequence shows in the registry browser. Before this change, `src/pages/reg/index.astro:71` built its filter key from `preferred_label` and `key` only (anchor against `staging` at `8bcfdca`), and the client filter — `src/pages/reg/index.astro:115-118` — is a plain substring match on that attribute. A scholar who typed `NE`, `EN`, or `Nikomachische Ethik` found nothing. The registry holds Aristotle's _Nicomachean Ethics_, but only under that one English string. The same gap hit `PI` for the _Philosophical Investigations_, `LXX` for the Septuagint, and every German, French, or Latin title a scholar uses by habit.
13+
14+
Two documents already promised the fix and named a field that did not exist: `src/content/docs/get-started/authoring.md:124` and `data/AGENTS.md:36` both said "alt-names belong in a future `alt_labels` field". The promise was two documents old and never had a schema behind it.
15+
16+
The registry is small today, so the cost is small. The roadmap plans growth across theology, classics, philosophy, and law. Works in those fields carry many established short forms. Search by one canonical English label does not scale to that.
17+
18+
This adds a field to a published record shape, so it needs an ADR rather than a `standard:` erratum. [#85](https://github.com/textrefs/textrefs.org/issues/85) laid out the four options below and recommended option 2.
19+
20+
## Decision drivers
21+
22+
- The registry browser must find a work by the name a scholar actually types, which in classics and theology is usually an abbreviation, not the English title.
23+
- Labels must never reach identity. ADR-0002 fixes the reference UUID seed at `work_key`, `citation_system_key`, `locator`; no label appears in it, and none may be added.
24+
- The change must be additive. Every existing record lacks the field, and the build must pass with no submodule data change.
25+
- No new namespace. `skos` is already declared at `public/contexts/v1.jsonld:4` and already carries `preferred_label`.
26+
- The language question applies to `preferred_label` too. Answering it for one field alone would leave the two inconsistent and make the eventual fix breaking for both.
27+
28+
## Considered options
29+
30+
1. **Do nothing.** Keep one label per work; tell clients to search the `preferred_label` string. Rejected: it leaves the registry unusable for a scholar who knows a work by its abbreviation, which is the common case in the fields the roadmap targets.
31+
2. **One flat list of plain strings**, published as `skos:altLabel`. ← chosen
32+
3. **Language-tagged label objects** — `{ value: string, language?: string }[]`. `LanguageTag` already exists at `standard/schema/common.ts:41-46` for `ResolverTargetEntry`, so the validator is available. Rejected for now, not on merit: it is the better end state, but it leaves `preferred_label` a bare string, and the inconsistency means the language question should be answered for both fields at once. See _Open questions_.
33+
4. **Separate `alternative_labels` and `abbreviations`.** More expressive, because a search UI can give an abbreviation an exact match and a full title a substring match. Rejected: it doubles the authoring burden and the boundary is not clear. `LXX` is an abbreviation. `Septuaginta` is an alternate title. `Sept.` is arguable, and an author should not have to decide.
34+
35+
## Decision
36+
37+
We choose **option 2**. `Work` gains an optional `alternative_labels: string[]`, published as `skos:altLabel` with `"@container": "@set"`.
38+
39+
The field is named **`alternative_labels`**, not the `alt_labels` that #85 and the two stale documents used. The registry spells its terms out — `preferred_label`, `preferred_citation_system_key`, `citation_system_key` — and an abbreviated key inside a field whose whole purpose is abbreviations reads as an accident.
40+
41+
**Identity.** An alternative label is never a UUID seed input (ADR-0002). Adding, editing, or removing one MUST NOT move an identifier. `scripts/compile.test.ts` locks this: compiling the same work under two different label lists yields identical reference IRIs. This rule is non-negotiable and is stated normatively in `specification.md` §6.
42+
43+
**Homonymy.** Two different works MAY claim the same alternative label. `Ethics` fits Aristotle and Spinoza. The compiler allows this and prints nothing. Homonymy is a fact about titles, not an authoring error, and the correct answer for a search field is two hits, not a rejected build. Consumers MUST treat such a match as ambiguous.
44+
45+
**Authoring.** Inside one work, entries MUST be unique and MUST NOT repeat the `preferred_label`. An empty list is rejected too: omit the key instead. This keeps the uniqueness rule strictly local, which is what makes the homonymy allowance above coherent.
46+
47+
These rules are enforced **twice**, because the two schemas answer different questions. `WorkSource.superRefine` (`scripts/source-schema.ts:191-207`) rejects the authored YAML at parse time with a field path, beside the duplicate-citation-system check. `Work.superRefine` (`standard/schema/work.ts:59-75`) rejects the compiled record, and it is the canonical schema the standard publishes — `scripts/validate-data.ts:36` and `scripts/compile.ts:519` both run it. Enforcing only the authoring shape would let a hand-built or third-party record carry a duplicate while still validating against the published schema, which would make the normative sentence in `specification.md` §6 unenforceable. The two report the same two messages, so a violation reads the same whichever layer catches it.
48+
49+
**Scope.** `CitationSystem` does not get the field in this ADR, even though it has the same problem — `Bekker` and `Stephanus` are the names scholars use, not the registry's `preferred_label` strings. One decision per ADR. See _Open questions_.
50+
51+
**Versioning.** No spec version bump. The standard stays `v0.1.0-draft` (`ROADMAP.md:7`, `specification.md:9`) and carries `maturity: working-draft`, which `/standard/versioning/` defines as "Unstable. Data model and prose may change without notice and without a version bump while the core is settled." ADR-0004 and ADR-0006 both invoked this clause; this ADR follows that precedent. The **data package** is a separate artefact and takes a **minor** SemVer bump at the next release under `/standard/versioning/#semver-rules-for-data-packages` — "Compatible new fields require a minor version increment" — which is automatic, since the compiler reads the version from `package.json`.
52+
53+
## Consequences
54+
55+
### Positive
56+
57+
- The registry browser finds a work by abbreviation and by translated title. This needed no client script change: the labels are folded into the existing `data-reg-item` haystack.
58+
- The field flows into `dist/dump/works.jsonl`, `/reg/works.json`, and `/id/work/{key}.json` with no further edit. `scripts/compile.ts:786-790`, `src/lib/collection.ts:11-15`, and `src/pages/id/work/[key].json.ts:15` all serialize the whole record rather than a field whitelist.
59+
- `skos:altLabel` is standard vocabulary paired with the `skos:prefLabel` already in the context, so consuming tools understand it without documentation and no new namespace is added.
60+
- The two stale `alt_labels` promises can finally be honoured.
61+
- Additive and optional, so no existing record changes and no identifier moves. The gate that would have caught an omission — `scripts/validate-data.ts:81-114`, which fails on any emitted key absent from the context — passes.
62+
63+
### Negative / trade-offs
64+
65+
- Every work record grows an optional field that reviewers must check for accuracy. An alternative label is a curatorial claim, like a mapping, but unlike a mapping it carries no `source` and no `status`. It is asserted by the record and reviewed only in the pull request.
66+
- Plain strings carry no language tag, so `Nikomachische Ethik` and `Ethica Nicomachea` sit in the same undifferentiated list. A client cannot filter labels by language or render the right one for its locale.
67+
- If a later ADR moves to language-tagged objects, the field shape breaks for existing consumers. Deferring the question is the cost paid for shipping search in one release.
68+
- Allowing cross-work duplicates means the browser can return two hits for one query with nothing to disambiguate them beyond the `key` shown in the row. That is correct, but it is a UI problem this ADR does not solve.
69+
- Nothing checks `public/contexts/v1.jsonld` against its hand-maintained copy at `src/content/docs/standard/json-ld.md:58`. This ADR adds one more term that can drift between them.
70+
71+
### Follow-up actions
72+
73+
- [x] Add `alternative_labels: z.array(z.string().min(1)).optional()` to `WorkBase` in `standard/schema/work.ts`, after `preferred_label`.
74+
- [x] Add `"alternative_labels": { "@id": "skos:altLabel", "@container": "@set" }` to `public/contexts/v1.jsonld`, and the same term to the copy in `src/content/docs/standard/json-ld.md`.
75+
- [x] Add the field to the strict `work` block of `WorkSource` in `scripts/source-schema.ts` with `.min(1)`, plus the uniqueness check in its `superRefine`. Mirror both in the canonical `Work` schema, so the published record shape enforces what the specification asserts.
76+
- [x] Project the field onto the compiled record in `scripts/compile.ts`, following the conditional-spread pattern the `creators` field uses. The record is built from an explicit field list, so an unlisted key is silently dropped.
77+
- [x] Mirror the field on the `Work` schema in `api/openapi.yaml`, as an optional array carrying `minItems: 1` and `uniqueItems: true`, so a downstream client validates against the same constraints.
78+
- [x] Fold the labels into the browser filter key in `src/pages/reg/index.astro`, and render them on `src/pages/id/work/[key]/index.astro`.
79+
- [x] Update `specification.md` — the mermaid `class Work` block, the `plato.republic` example, the `Optional:` list, and one normative paragraph in §6 stating the identity and homonymy rules.
80+
- [x] Replace the `alt_labels` promise in `src/content/docs/get-started/authoring.md` with a real `### alternative_labels` section.
81+
- [x] Add the field to the fixture work in `src/lib/registry.fixture.ts`, so `npm run build:fast` exercises both the record page and the filter without submodule data.
82+
- [x] Extend `scripts/compile.test.ts`: projection, omission, empty list, in-work duplicate, a label equal to `preferred_label`, a shared label across two works, and identifier stability under a label change — through the YAML path, and again directly against the canonical `Work` schema.
83+
- [ ] Fix the stale `alt_labels` promise at `data/AGENTS.md:36`. That file lives in `textrefs/registry`, so it needs a pull request to the submodule repository.
84+
- [ ] Seed alternative labels on `data/works/*.yaml` in a `textrefs/registry` pull request — `NE`/`EN` for the _Nicomachean Ethics_, `PI` for the _Philosophical Investigations_ — then bump the submodule pointer here. Follow the workflow in `CONTRIBUTING.md:135-140`.
85+
- [ ] **Separate ADR or issue:** disambiguate a duplicate hit in the registry browser. Two works sharing a label is legal by this decision, and the row shows only the `key`.
86+
- [ ] **Separate ADR:** language tagging for labels, answered for `preferred_label` and `alternative_labels` together. See _Open questions_ 1.
87+
88+
## Open questions
89+
90+
Recorded, not answered here.
91+
92+
1. **Plain strings now, or language-tagged objects?** Deferring costs a breaking record-shape change later: consumers reading `alternative_labels` as `string[]` would have to move to `{ value, language }[]`. Answering it now would leave `preferred_label` a bare string and the two fields inconsistent, and fixing that inconsistency is itself breaking. The question belongs to both fields at once.
93+
2. **One flat list, or separate `abbreviations` and alternate titles?** A search UI treats them differently — an abbreviation deserves an exact match, a full title a substring match. Option 4 above rejected the split on authoring cost, not on merit. Revisit if the browser gains ranked search.
94+
3. **Does `CitationSystem` need the same field?** It has the same problem: `Bekker` and `Stephanus` are the names scholars use. Out of scope for v1. If the answer is yes, it is additive and non-breaking at that point.
95+
4. **Is there an upper bound on the number of labels per work?** None is enforced. A work with thirty labels would bloat the filter haystack and the dump without an obvious benefit. No evidence yet on where a sensible limit sits.
96+
97+
## Links
98+
99+
- Related ADRs: ADR-0002 (the UUID seed — the reason a label can never move an identifier), ADR-0005 (introduces the Zod authoring schema this field extends), ADR-0006 (`alternateOf` is a mapping projection, not a label field)
100+
- Related issues / PRs: textrefs/textrefs.org#85
101+
- External references: [SKOS `altLabel`](https://www.w3.org/TR/skos-reference/#labels), [JSON-LD `@container: @set`](https://www.w3.org/TR/json-ld11/#sets-and-lists)

‎public/contexts/v1.jsonld‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
"MappingAssertion": "tr:MappingAssertion",
1515
"key": "tr:key",
1616
"preferred_label": "skos:prefLabel",
17+
"alternative_labels": {
18+
"@id": "skos:altLabel",
19+
"@container": "@set"
20+
},
1721
"description": "dcterms:description",
1822
"creators": "schema:creator",
1923
"kind": "tr:creatorKind",

‎scripts/compile.test.ts‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type CompiledRegistry,
1919
} from './compile.js';
2020
import { LanguageTag } from '../standard/schema/common.js';
21+
import { Work } from '../standard/schema/work.js';
2122

2223
type RegistryFiles = {
2324
systems: Record<string, string>;
@@ -267,6 +268,165 @@ references:
267268
}
268269
});
269270

271+
// --- Alternative labels (ADR-0007) ----------------------------------------
272+
273+
/** A work header whose `work:` block carries an `alternative_labels:` list. */
274+
const workWithLabels = (labels: string[]) => ({
275+
systems: twoSystems,
276+
works: {
277+
'test.work': `
278+
work:
279+
key: test.work
280+
preferred_label: Test Work
281+
alternative_labels:
282+
${labels.map((l) => ` - '${l}'`).join('\n')}
283+
status: active
284+
created: 2026-01-01
285+
modified: 2026-01-01
286+
287+
citation_system: primary-section
288+
references:
289+
- '5'
290+
`,
291+
},
292+
});
293+
294+
test('authored alternative labels reach the compiled work in order', () => {
295+
const reg = compileFixture(workWithLabels(['TW', 'Testwerk']));
296+
const work = reg.works.find((w) => w.key === 'test.work');
297+
assert.ok(work);
298+
assert.deepEqual(work.alternative_labels, ['TW', 'Testwerk']);
299+
});
300+
301+
test('a work without alternative labels omits the key entirely', () => {
302+
const reg = compileFixture({
303+
systems: twoSystems,
304+
works: {
305+
'test.work': `${workHeader()}
306+
citation_system: primary-section
307+
references:
308+
- '5'
309+
`,
310+
},
311+
});
312+
const work = reg.works.find((w) => w.key === 'test.work');
313+
assert.ok(work);
314+
assert.ok(!('alternative_labels' in work));
315+
});
316+
317+
test('an empty alternative_labels list is an authoring error', (t) => {
318+
const message = expectCompileError(t, {
319+
systems: twoSystems,
320+
works: {
321+
'test.work': `
322+
work:
323+
key: test.work
324+
preferred_label: Test Work
325+
alternative_labels: []
326+
status: active
327+
created: 2026-01-01
328+
modified: 2026-01-01
329+
330+
citation_system: primary-section
331+
references:
332+
- '5'
333+
`,
334+
},
335+
});
336+
assert.match(message, /invalid source file/);
337+
});
338+
339+
test('repeating one alternative label within a work is an authoring error', (t) => {
340+
const message = expectCompileError(t, workWithLabels(['TW', 'TW']));
341+
assert.match(message, /invalid source file/);
342+
});
343+
344+
test('an alternative label equal to the preferred label is an authoring error', (t) => {
345+
const message = expectCompileError(t, workWithLabels(['Test Work']));
346+
assert.match(message, /invalid source file/);
347+
});
348+
349+
test('two different works may share one alternative label', () => {
350+
const shared = (key: string, label: string) => `
351+
work:
352+
key: ${key}
353+
preferred_label: ${label}
354+
alternative_labels:
355+
- 'Ethics'
356+
status: active
357+
created: 2026-01-01
358+
modified: 2026-01-01
359+
360+
citation_system: primary-section
361+
references:
362+
- '5'
363+
`;
364+
const reg = compileFixture({
365+
systems: twoSystems,
366+
works: {
367+
'aristotle.ne': shared('aristotle.ne', 'Nicomachean Ethics'),
368+
'spinoza.ethica': shared('spinoza.ethica', 'Ethica'),
369+
},
370+
});
371+
const claimants = reg.works.filter((w) =>
372+
(w.alternative_labels ?? []).includes('Ethics'),
373+
);
374+
assert.equal(claimants.length, 2);
375+
});
376+
377+
// An alternative label is not a UUID seed input (ADR-0002), so editing one
378+
// must leave every minted identifier where it was.
379+
test('alternative labels never move a reference identifier', () => {
380+
const withLabels = compileFixture(workWithLabels(['TW']));
381+
const withOtherLabels = compileFixture(workWithLabels(['Testwerk', 'TW-2']));
382+
assert.deepEqual(
383+
withLabels.references.map((r) => r.id),
384+
withOtherLabels.references.map((r) => r.id),
385+
);
386+
});
387+
388+
// The rules above are enforced on the published record too, not only on the
389+
// authored YAML — a hand-built record that reaches a consumer must not be able
390+
// to carry an empty list, a repeat, or the preferred label.
391+
const workRecord = (alternative_labels?: string[]) => ({
392+
id: 'https://textrefs.org/id/work/test.work',
393+
key: 'test.work',
394+
type: 'Work' as const,
395+
preferred_label: 'Test Work',
396+
...(alternative_labels ? { alternative_labels } : {}),
397+
preferred_citation_system_key: 'primary-section',
398+
status: 'active' as const,
399+
created: '2026-01-01',
400+
modified: '2026-01-01',
401+
});
402+
403+
test('the canonical Work schema accepts a well-formed label list', () => {
404+
assert.equal(Work.safeParse(workRecord(['TW', 'Testwerk'])).success, true);
405+
assert.equal(Work.safeParse(workRecord()).success, true);
406+
});
407+
408+
test('the canonical Work schema rejects an empty label list', () => {
409+
assert.equal(Work.safeParse(workRecord([])).success, false);
410+
});
411+
412+
test('the canonical Work schema rejects a repeated label', () => {
413+
const result = Work.safeParse(workRecord(['TW', 'TW']));
414+
assert.equal(result.success, false);
415+
assert.match(
416+
result.error?.issues[0]?.message ?? '',
417+
/declared more than once/,
418+
);
419+
});
420+
421+
test('the canonical Work schema rejects a label equal to preferred_label', () => {
422+
const result = Work.safeParse(workRecord(['Test Work']));
423+
assert.equal(result.success, false);
424+
assert.match(
425+
result.error?.issues[0]?.message ?? '',
426+
/repeats the preferred label/,
427+
);
428+
});
429+
270430
// --- Mapping relation vocabulary (ADR-0006) -------------------------------
271431

272432
const mappings = `

0 commit comments

Comments
 (0)