240 lines
25 KiB
Markdown
240 lines
25 KiB
Markdown
# Article versions, translations, revisions and story move
|
|
|
|
Contract for `features/article_translation_create.feature`, `features/article_edit_revision.feature` and `features/article_story_lifecycle.feature`. The SPA mocks `/v1` from this page. The article object itself is described in [articles.md](articles.md), the languages of a site in [site-languages.md](site-languages.md).
|
|
|
|
## Vocabulary
|
|
|
|
- A **story** groups the language versions of one article inside one site. A **version** is one article row: it has its own id, `lang`, title, slug, content, publication and `created_by`. A story holds at most one live version per language.
|
|
- `revision` is the edit counter of a version, starting at `1`, raised by `1` per saved change (see [What raises revision](#what-raises-revision)). An internal content counter is also kept; it is never exposed on a version, only as `source.revision`.
|
|
- A **reader** of a version is a caller who passes the existing read rule: an online version is readable by everyone, an offline one by a verified caller whose `content.read` covers it (`all`, or `own` and the caller created it).
|
|
- **Missing means no readable version.** Everything an article read says about sibling versions (translations, source, error bodies, headers, counts) is limited to the versions the caller can read. No count, placeholder, total, header or error text ever names, counts or hints at a version the caller cannot read.
|
|
|
|
## Errors
|
|
|
|
Errors are plain text. The body is the code (or an existing message) followed by a line feed, and extra data travels in response headers. A refused request changes nothing.
|
|
|
|
| Header | Set on | Value |
|
|
| --- | --- | --- |
|
|
| `X-Current-Revision` | `409 revision_conflict` | the current `revision` of the version, an integer |
|
|
| `X-Existing-Article-Id` | `409 language_exists` | id of the readable version that already holds the language |
|
|
| `X-Conflicting-Articles` | `409 slug_conflict` | ids of the readable versions whose address is taken at the destination, comma separated, in creation order. Absent when none of them is readable |
|
|
| `X-Missing-Languages` | `422 language_not_enabled` on a move or on `PATCH site_id` | tags missing or disabled at the destination, comma separated, sorted, readable versions only. Absent when empty |
|
|
| `X-Slug-Suggestion` | `409 slug_taken` | first free `<slug>-N` (existing) |
|
|
| `X-Missing-Permission` | `403 missing_permission` | the permission key (existing) |
|
|
|
|
The headers are set on those responses only. Same-origin SPA code reads them directly; cross-origin scripts do not see them (`Access-Control-Expose-Headers` is unchanged).
|
|
|
|
| Status | Body | Meaning |
|
|
| --- | --- | --- |
|
|
| 400 | `The request cannot be processed due to a mismatch in the format of the body.` | undecodable or invalid body, missing `copy`, `copy: false` with a source, `if_revision` invalid, an unknown or empty update |
|
|
| 400 | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` | invalid id in the path |
|
|
| 400 | the validator text (for example `Key: 'PostTranslation.Title' Error:Field validation for 'Title' failed on the 'min' tag`) | `lang` or `title` missing, title shorter than 3 or longer than 200 characters on a create, `site` that is `""` or not a site key on `POST /v1/article/` |
|
|
| 400 | `Article title must be between 3 and 200 characters.` | a `PATCH` title outside 3 to 200 runes |
|
|
| 400 | `invalid_slug` | a `PATCH` slug that is not a slug |
|
|
| 401 | `You must be logged in to access this resource.` | no valid session or key |
|
|
| 403 | `missing_permission` | the caller lacks the permission (`X-Missing-Permission`) |
|
|
| 404 | `Article not found` | the version, or the story, does not exist or the caller cannot read it |
|
|
| 404 | `source_unavailable` | the chosen source does not exist or is not readable; the same answer for both |
|
|
| 404 | `site_not_found` | `POST /v1/story/{story_id}/move`, or `POST /v1/article/` with `site`, naming an unknown site key |
|
|
| 409 | `revision_conflict` | `if_revision` is not the current `revision` (`X-Current-Revision`) |
|
|
| 409 | `language_exists` | the language already has a readable version (`X-Existing-Article-Id`) |
|
|
| 409 | `language_unavailable` | one neutral answer: the language is disabled on the site (the chosen site on `POST /v1/article/` with `site`), or its version exists but is not readable by the caller, or a concurrent request created it first |
|
|
| 409 | `setup_required` | the version has no language yet (`lang` `""`): the site default is not confirmed (the chosen site on `POST /v1/article/` with `site`) |
|
|
| 409 | `slug_taken` | a `PATCH` slug used by another article of the same site and language (`X-Slug-Suggestion`) |
|
|
| 409 | `slug_conflict` | a move would put an address that already exists at the destination (`X-Conflicting-Articles`) |
|
|
| 409 | `story_move_required` | `PATCH site_id` on a version whose story holds several versions |
|
|
| 409 | `story_busy` | the database could not lock the rows in time (lock timeout, deadlock or serialization failure); retry |
|
|
| 422 | `invalid_language` | the tag is not a language tag |
|
|
| 422 | `unsupported_language` | a valid tag outside the supported list |
|
|
| 422 | `language_not_enabled` | the site (the chosen one on `POST /v1/article/` with `site`) has no language row for the tag (creation), or a destination site lacks or has disabled a language of the story (move, `X-Missing-Languages`) |
|
|
| 422 | `language_disabled` | publishing an offline version whose language is disabled on its site |
|
|
| 422 | `source_other_story` | the source belongs to another story |
|
|
|
|
## Fields added to an article read
|
|
|
|
Added to the article object of `GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`, and for `revision` also to each item of `GET /v1/article/`. Every other key keeps its name, value and type.
|
|
|
|
```json
|
|
{
|
|
"id": 10,
|
|
"lang": "en",
|
|
"story_id": 5,
|
|
"revision": 3,
|
|
"capabilities": ["read", "edit", "publish", "delete", "create_translation"],
|
|
"translations": [
|
|
{"id": 10, "lang": "en", "title": "Renovation costs", "is_online": true, "language_enabled": true, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]},
|
|
{"id": 13, "lang": "ar", "title": "Takalif", "is_online": true, "language_enabled": true, "capabilities": ["read", "create_translation"]},
|
|
{"id": 12, "lang": "fr", "title": "Couts", "is_online": false, "language_enabled": false, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]}
|
|
],
|
|
"source": {"article_id": 10, "revision": 4}
|
|
}
|
|
```
|
|
|
|
- `revision` is a positive integer on every read and write, public reads included.
|
|
- `capabilities` lists what the caller may do with this version, always in this order and always starting with `read`: `edit` (`content.update` covers it), `publish` (`content.publish`), `delete` (`content.delete`), `create_translation` (the caller holds `content.create`). An anonymous caller gets `["read"]`.
|
|
- `translations` lists the readable versions of the story, the version being read included, with `id`, `lang`, `title`, `is_online`, `language_enabled` and their own `capabilities`. A version without language (`lang` `""`) reports `language_enabled: true`. The order is the default language of the site first, then the other tags in ascending order (`en`, `ar`, `de`, `fr`); a disabled language is still listed with `language_enabled: false`. A single-version article lists itself. An anonymous caller gets `[]`.
|
|
- `source` is `{"article_id", "revision"}` when the version was created by copying another version and that source still exists and is readable by the caller, otherwise `null` (a version created empty, a deleted source, an unreadable source, an anonymous caller). `source.revision` is the content revision of the source at the moment of the copy.
|
|
|
|
## What raises revision
|
|
|
|
A `PATCH` raises `revision` by exactly `1` when it changes at least one stored value, whatever the number of fields, and not at all when it changes nothing. Publishing and unpublishing never raise it.
|
|
|
|
| Changed field | `revision` | internal content revision |
|
|
| --- | --- | --- |
|
|
| `title`, `body`, `excerpt`, `slug`, `cover_image_alt`, `seo_title`, `seo_description`, `social_title`, `social_description` | +1 | +1 |
|
|
| `author_ids`, `cover_image_id`, `social_image_id`, `canonical_url`, `noindex`, `seo_dismissed`, `site_id` | +1 | unchanged |
|
|
|
|
Existing rows start at revision `1`. A story move raises each moved version by `1`.
|
|
|
|
## Editing: `PATCH /v1/article/{id}`
|
|
|
|
Verified session or API key with `content.update` on the version (`403 missing_permission` otherwise, checked first) and able to read it (`404 Article not found` otherwise, checked second), so a stale `if_revision` never reveals anything to a caller who cannot edit or read the version.
|
|
|
|
- `title`: 3 to 200 runes (a 4-byte emoji counts as one). `""`, 2 and 201 runes answer `400 Article title must be between 3 and 200 characters.`; `null` answers `400` with the body-mismatch message. Editing the title never changes the slug and never changes the publication: an online version shows the new title publicly at once.
|
|
- `slug`: changed only when sent. `""` regenerates it from the current title. There is no redirect, the old address stops resolving. Unique per site and language: the same slug in two languages of a story is allowed. A slug already used by another article of the same site and language answers `409 slug_taken` with `X-Slug-Suggestion`. Sending the current slug changes nothing.
|
|
- `if_revision`: optional integer `>= 1`. A value that is `0`, negative, a string, a fraction or `null` answers `400` (body mismatch). `if_revision` alone is not an update (`400`). When it differs from the current `revision`, nothing is stored and the answer is `409 revision_conflict` with `X-Current-Revision`, even when the rest of the request would change nothing. The compare and the write are one atomic step: of two concurrent saves with the same `if_revision`, one answers `200` and the other `409`. Without `if_revision` the last write wins. To overwrite after a conflict, send `if_revision` equal to the `X-Current-Revision` the conflict showed; if somebody saved in between, the answer is `409` again with the newer revision.
|
|
- `site_id` on a version whose story holds several versions the caller can read answers `409 story_move_required` and changes nothing (hidden versions are not counted, so the answer never reveals them). When the caller reads only this version of a larger story, `content.update` is required on every version of the story, readable or not (`403 missing_permission` otherwise, naming no version) and the request runs [the story move](#moving-a-story-post-v1storystory_idmove) rules: every version moves in one transaction, language and slug refusals roll everything back, and headers name readable versions only. The other fields of the same request (`title`, `author_ids`, `slug`, SEO fields) are part of that transaction: a refusal of any of them, an unknown author or an invalid SEO value included, leaves every version on its original site and every `revision` unchanged, and the edited version's `revision` rises by exactly 1 for the whole request. To move a story with several readable versions, use [the story move](#moving-a-story-post-v1storystory_idmove). On a story with one version it moves the version when its language is enabled at the destination (`422 language_not_enabled`, with `X-Missing-Languages`, otherwise); a version without language moves without a language check. The slug rule of the destination still applies (`409 slug_taken`).
|
|
- `lang`, `story_id`, `source_article_id` and `copy` in the body are ignored.
|
|
|
|
The `200` body is the full article object with the new `revision`.
|
|
|
|
## Publishing
|
|
|
|
`PATCH /v1/article/{id}/publish` on an offline version whose language is disabled on its site answers `422 language_disabled`. Publishing an already online version is idempotent (`200`, unchanged) even when its language is disabled; a version without language publishes as before. `unpublish` and `PATCH` stay available in a disabled language. Publishing or unpublishing one version never touches its siblings.
|
|
|
|
## Creating a translation
|
|
|
|
Two entry points share one implementation: the same result, errors, headers and stored data.
|
|
|
|
### `POST /v1/article/{id}/translations`
|
|
|
|
Verified session or API key with `content.create`; the version `{id}` must be readable by the caller.
|
|
|
|
```json
|
|
{"lang": "fr", "title": "Couts de renovation", "copy": true, "source_article_id": 11}
|
|
```
|
|
|
|
| Field | Rule |
|
|
| --- | --- |
|
|
| `lang` | required; normalized (`FR` is stored as `fr`) |
|
|
| `title` | required, 3 to 200 characters |
|
|
| `copy` | required boolean (`400` when absent) |
|
|
| `source_article_id` | optional, only with `copy: true` (`400` with `copy: false`); default is `{id}`; must be a readable version of the same story |
|
|
|
|
The new version joins the story of `{id}`, in the same site, as an offline draft created by the caller, with `revision` `1`. The slug is generated from the new title in (site, language) and gets the next free `-N` suffix when taken.
|
|
|
|
- `copy: true` copies `body`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_id`, `cover_image_alt`, `social_image_id` and the authors from the source, and never `slug`, `canonical_url` (empty), `noindex` (false) or `seo_dismissed` (empty). No image or file row is duplicated. The source and its content revision are captured in the same transaction as the copy: the read shows `"source": {"article_id": <source>, "revision": <content revision then>}`.
|
|
- `copy: false` starts empty: empty text, no images, `authors` is the creating user's byline, `"source": null`.
|
|
|
|
Answers `201` with the created article object. Order of checks: permission, path id, body, version readable, `copy` rules, language tag, `setup_required`, source, language configuration. A story deleted in the meantime answers `404 Article not found`. Both entry points leave the stored `lang` normalized.
|
|
|
|
### `POST /v1/article/` with `story_id`
|
|
|
|
```json
|
|
{"title": "Couts", "lang": "fr", "story_id": 5, "copy": true, "source_article_id": 10}
|
|
```
|
|
|
|
`story_id` selects the story (`404 Article not found` when it has no readable version, the same answer as a missing story) and the first readable version, oldest first, plays the role of `{id}`. `lang` is then required (`400`). `source_article_id` and `copy` are only valid with `story_id` (`400` otherwise).
|
|
|
|
### `POST /v1/article/` without `story_id`
|
|
|
|
It creates a new story with one offline version, on the default site unless the body names a site.
|
|
|
|
```json
|
|
{"title": "Neuf", "lang": "fr", "site": "atelier"}
|
|
```
|
|
|
|
- `site` is optional: the site key. Absent or `null`, the default site is used exactly as before. A present `site` is validated like any site key (`400` with the plain-text body `invalid_site_key` for `""` or `Bad Key!`), then looked up (`404 site_not_found` for an unknown key, a plain text body, nothing stored). Every caller who may create articles may create on every site: `content.create` is not scoped to a site.
|
|
- `lang` is optional. Absent, the version gets the starting language of the chosen site; present, it is normalized and must be an enabled language of the chosen site (`422 invalid_language`, `unsupported_language`, `language_not_enabled`; `409 language_unavailable` when disabled on that site; `409 setup_required` before that site's default language is confirmed). The same tag can therefore be accepted on one site and refused on another.
|
|
- The slug is generated from the title and made free within the chosen site and language only.
|
|
- `site` with `story_id`, `source_article_id` or `copy` answers `400` with the body mismatch message and stores nothing. `POST /v1/article/{id}/translations` has no `site` field: the translation keeps the site of its source and an unknown `site` key in its body is ignored.
|
|
- The response is the created article object (`201`) with the same keys as before; `site_id` is the id of the chosen site. The article is offline, so it is invisible to anonymous reads and public lists, and `GET /v1/article/?site=<key>` lists it for readers.
|
|
- `article.create` records `params.site` (the site key) on success; a refusal records one failed event with a plain reason (refusals by validation, `401` and `403` happen before the use case and record nothing).
|
|
|
|
## Deleting
|
|
|
|
`DELETE /v1/article/{id}` removes one version (`204`, empty body) and checks `content.delete` per version (`403 missing_permission`). Other versions and their story remain. Deleting the last version of a story removes the story. Nothing in the answer mentions a sibling. A deleted language can be created again in the same story; a language with a deleted version still counts as in use for removing the language from the site.
|
|
|
|
## Moving a story: `POST /v1/story/{story_id}/move`
|
|
|
|
```json
|
|
{"site": "atelier"}
|
|
```
|
|
|
|
Verified session or API key. The caller needs `content.update` on every version of the story, readable or not, and must read at least one (`404 Article not found` otherwise, also for an unknown story). `site` is the key of the destination (`400` when absent or empty, `404 site_not_found` when unknown). Answers `200`:
|
|
|
|
```json
|
|
{"story_id": 5, "site": "atelier", "site_id": 2, "article_ids": [10, 12, 11, 13]}
|
|
```
|
|
|
|
`article_ids` lists the readable versions only, oldest first. Every version moves in one transaction: `site_id` changes, slugs, languages, publication, content and authors are unchanged, `revision` of each moved version rises by 1 and its content revision does not. Moving to the current site answers `200` and changes nothing.
|
|
|
|
Everything is checked inside the transaction, under the locks of both sites, and nothing moves when something fails: every language of every version (readable or not) must be enabled at the destination (`422 language_not_enabled`, `X-Missing-Languages` limited to languages of readable versions, a language disabled at the destination counts as missing, a language disabled at the source does not matter), and no address may exist at the destination in the same language (`409 slug_conflict`, `X-Conflicting-Articles` limited to readable versions). A conflict that appears between the check and the write answers `409 slug_conflict` without the header.
|
|
|
|
## Editorial list by story: `GET /v1/article/?group=story`
|
|
|
|
An opt-in shape of the list for a signed-in, verified caller whose role resolves. Without `group` the response keeps the flat envelope `{ items, total, page, size, counts }` of the paginated list, whose `counts` are `all`, `online`, `draft` and `suggestions`. A caller without a valid session, or with an unverified user, who sends `group` gets 400 with the body-mismatch message and nothing is read.
|
|
|
|
| Parameter | Meaning |
|
|
| --- | --- |
|
|
| `group` | Only `story`. `group=`, any other value, or `group` from a public caller is 400. |
|
|
| `lang` | A supported language tag, normalized like the language of a version (`fr`, `pt-br` becomes `pt-BR`). Empty, malformed or unsupported is 400. Also narrows the flat list (`items`, `total`, `counts.all`, `counts.online`, `counts.draft`). Disabled languages can be selected. |
|
|
| `tab` | `all` (default) or `missing`. `review`, an empty value, any other value, and `missing` without `group=story` are 400. |
|
|
| `author` | A byline id, an integer of at least 1; anything else is 400. Also narrows the flat list. |
|
|
| `page`, `size`, `q`, `status`, `site`, `seo`, `sort` | Same values, defaults and validation as the flat list. |
|
|
|
|
Only versions the caller can read count: they are titled, credited, listed in `coverage`, searched and counted. A hidden version never appears, and a story whose versions are all hidden has no row.
|
|
|
|
**Rows.** With no `lang` a row is a story: `total` counts stories. With `lang` a row is the version of that language (`tab=all`), and the whole story's `coverage`, `story_authors` and `missing_langs` still ride on the row. With `tab=missing` rows are stories again: `lang=fr` lists the stories on a site that enables `fr` and that have a readable version but none in `fr`; without `lang` it lists the stories that lack at least one enabled language, and a caller who cannot create (no `content.create`) gets `total` 0. Articles without language (`lang` `""`) are one story each and are never listed as missing a language. A version of the story must satisfy `status`, `author`, `q` and `seo` together to make the story match; `q` ignores case and accents.
|
|
|
|
**Item.** The article list item of the representative version (the one in `lang`, otherwise the site default language version, otherwise the first readable one by language tag), plus:
|
|
|
|
| Field | Type | Meaning |
|
|
| --- | --- | --- |
|
|
| `story_updated_at` | RFC 3339 | The latest `updated_at` among the readable versions of the story |
|
|
| `story_authors` | array of bylines | The union of the bylines of the readable versions, without duplicates |
|
|
| `coverage` | array | One entry per readable version, in the order of `translations` (default language, other enabled tags, disabled tags): `{ id, lang, title, is_online, language_enabled, capabilities }` |
|
|
| `missing_langs` | array of tags | Enabled languages without a readable version, default first then by tag; `[]` when the caller cannot create |
|
|
| `capabilities` | array | The capabilities of the representative version |
|
|
|
|
**Sort.** The whitelist is the flat one. `updated_at` uses the latest readable version of the story, `created_at` the earliest, `title` and `status` the representative; with `lang` the version itself. Ties end with `story_id` descending.
|
|
|
|
**Counts.** `{ all, online, draft, suggestions, missing }`. They follow `lang`, `q`, `author`, `seo` and `site` but never `status` or `tab`, and count the unit of the mode (stories, or versions of the selected language). `online` and `draft` count the units with a readable online or draft version. `missing` is the `total` the not-in view would give under the same filters (0 without `lang` for a caller who cannot create). `suggestions` is the number of stories with a readable version whose `seo_status` is `suggestions`, ignoring every parameter.
|
|
|
|
A repository failure at any step, counts included, answers 500 `Failed to retrieve articles`.
|
|
|
|
## Mock-ready examples
|
|
|
|
Statuses of the new and changed calls. Bodies are plain text unless shown as JSON; headers listed are the only extra ones.
|
|
|
|
| Call | Status | Headers | Body |
|
|
| --- | --- | --- | --- |
|
|
| `GET /v1/article/10` (see above) | 200 | | article object with `revision`, `capabilities`, `translations`, `source` |
|
|
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts","copy":true}` | 201 | | article object, `lang` `"fr"`, `"is_online": false`, `"revision": 1`, `"source": {"article_id": 10, "revision": 4}` |
|
|
| `DELETE /v1/article/12` | 204 | | empty |
|
|
| `POST /v1/story/5/move` `{"site":"atelier"}` | 200 | | `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}` |
|
|
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts"}` | 400 | | `The request cannot be processed due to a mismatch in the format of the body.` |
|
|
| `PATCH /v1/article/10` `{"title":"ab"}` | 400 | | `Article title must be between 3 and 200 characters.` |
|
|
| `POST /v1/article/10/translations` with a cookie of an anonymous client | 401 | | `You must be logged in to access this resource.` |
|
|
| `POST /v1/story/5/move` by a user holding `content.update` at `own` | 403 | `X-Missing-Permission: content.update` | `missing_permission` |
|
|
| `POST /v1/article/9999/translations` | 404 | | `Article not found` |
|
|
| `POST /v1/article/10/translations` with `"source_article_id":9999` | 404 | | `source_unavailable` |
|
|
| `POST /v1/story/5/move` `{"site":"nowhere"}` | 404 | | `site_not_found` |
|
|
| `PATCH /v1/article/10` `{"body":"x","if_revision":3}` after somebody saved | 409 | `X-Current-Revision: 4` | `revision_conflict` |
|
|
| `POST /v1/article/10/translations` `{"lang":"de","title":"Kosten","copy":true}` | 409 | `X-Existing-Article-Id: 11` | `language_exists` |
|
|
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts","copy":true}` with fr disabled, unreadable or raced | 409 | none | `language_unavailable` |
|
|
| `POST /v1/article/30/translations` on an article without language | 409 | | `setup_required` |
|
|
| `PATCH /v1/article/10` `{"slug":"taken-en"}` | 409 | `X-Slug-Suggestion: taken-en-2` | `slug_taken` |
|
|
| `POST /v1/story/5/move` `{"site":"atelier"}` with an address taken at the destination | 409 | `X-Conflicting-Articles: 12` | `slug_conflict` |
|
|
| `PATCH /v1/article/10` `{"site_id":2}` on a story with several readable versions | 409 | | `story_move_required` |
|
|
| `POST /v1/article/10/translations` `{"lang":"pt","title":"Custos","copy":true}` with no `pt` row | 422 | | `language_not_enabled` |
|
|
| `POST /v1/story/5/move` `{"site":"atelier"}` with no `ar` at the destination | 422 | `X-Missing-Languages: ar` | `language_not_enabled` |
|
|
| `PATCH /v1/article/15/publish` with `es` disabled | 422 | | `language_disabled` |
|
|
| `POST /v1/article/10/translations` `{"lang":"xx","title":"Couts","copy":true}` | 422 | | `unsupported_language` |
|
|
| `POST /v1/article/10/translations` `{"lang":"english","title":"Couts","copy":true}` | 422 | | `invalid_language` |
|
|
| `POST /v1/article/10/translations` with `"source_article_id":20` of another story | 422 | | `source_other_story` |
|
|
|
|
## Activity
|
|
|
|
No new action code. The existing events carry `params.lang` (the language of the version, omitted for a version without language): `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete`. A translation creation records `article.create` on the new version with `params.lang`, `params.story_id` and `params.source_article_id` (only when copied). A story move records one `article.edit` per moved version, with a `site` change (site names before and after) and `params.lang`; moving to the current site records nothing. A refused creation or move records one failed event with a plain reason that names no version; refusals answered `404` are not recorded.
|