Skip to content

feat(publication): public per-release route - #681

Open
midego1 wants to merge 3 commits into
unopim:3.xfrom
midego1:feat/publication-release-route
Open

midego1 wants to merge 3 commits into
unopim:3.xfrom
midego1:feat/publication-release-route

Conversation

@midego1

@midego1 midego1 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Stacked on #680 and #679

This PR shows their commits until they land. Only the last commit (feat(publication): public per-release route) is new here.

Problem

Every public passport URL resolves to the current state. A QR code printed into a manual two years ago therefore shows today's data. The regulation asks for the opposite: the state the product was placed on the market with must stay reachable, and one printed reference must keep meaning that one state. #680 gave that state a name (a release, spanning all locales); nothing serves it yet.

Change

Route GET /{prefix}/{uuid}/r/{sequence}/{locale} in the per-type group, where('sequence', '[1-9][0-9]{0,9}'). Four segments with a literal r, so it cannot shadow the existing two-segment routes. Resolved via PublicationRelease::versionsAsOf(): for the requested locale, the most recent version minted at or before that release.

Semantics

  • Strict locale. No Accept-Language negotiation on an explicit historical URL: a reference must not resolve to a different document per reader. A locale that had no version yet at that release is 404 there.
  • 200 with a banner naming the release and whether that state is still current, linking to the live page. The locale switcher stays inside the release.
  • Never indexed: X-Robots-Tag: noindex, noarchive, nofollow, a <link rel="canonical"> and a Link: <…>; rel="canonical" header pointing at the live page. No JSON-LD negotiation on release pages: a historical payload's stamped identity is the publication URL, which would misdescribe it.
  • A version that was redacted individually renders the tombstone with 410 and no-store, even while the publication itself stays Published. The same check now applies on the live route, which previously handed the template a null payload while telling it the page was not withdrawn.
  • Release pages are not counted as views.

ETag now also covers the release sequence, whether the version is still current, and whether it is redacted: the banner text and the tombstone change the HTML without changing the payload checksum. TEMPLATE_VERSION bumped to invalidate cached live pages.

Refactor: show() and the new showRelease() share one private render(); behaviour of the live route is unchanged apart from the redacted-version case above.

Strings: four new keys under publication::app.public.release, present in all 33 locales (English placeholders where no translation exists yet). unopim:translations:check passes.

Tests

Stub-template route tests (PublicPassportRouteTest):

  • earlier release served at its URL with banner, canonical Link, no indexing, and the earlier payload rather than the newer one; latest release marked current
  • unknown sequence, 0 and non-numeric are 404
  • a release whose version was redacted answers 410 no-store while the live page still serves the newer state
  • the ETag of a release page changes once that state stops being current, and 304 works against the new one
  • strict per-locale resolution across two locales published in different releases

Real template (PassportPageRenderTest): banner, <link rel="canonical">, noindex, switcher links inside the release, and no JSON-LD on a release page.

Related

Follows #677, #678, #679 and #680. Next on this track: an issuance record binding a printed carrier to the release it was issued against, and GS1 Digital Link qualifiers (/01/{gtin}/10/{lot}).

…moment

Versions are numbered per locale, so "version 3" names a different
state in every language. Nothing identifies one moment across locales,
which a printed carrier needs: the passport state a product was placed
on the market with is one moment, not one per language.

Add `publication_releases`: one row per minted version, with a
`sequence` that is monotonic per publication, minted inside the same
lock as the version number. `publication_versions.release_id` points at
it. Existing versions are backfilled, one release each in publish order.

`PublicationRelease::versionsAsOf()` resolves the state as of a release:
for every locale, the most recent version minted at or before it. That
is the read a per-release public route needs and nothing else; this
change alters no public behaviour.

Releases are immutable like versions (update/delete throw), and
`release_id` is sealed on the version.
… out of shared caches

`Publisher::redactAll()` redacted only the `is_current` versions before
flipping the publication to Redacted. Every superseded version kept its
sealed payload readable in the database, so a GDPR Art. 17 erasure held
only for as long as nothing ever read history. Now every not-yet-redacted
version of the publication is redacted; versions already redacted on
their own are left untouched.

The public tombstone changes with it. A redacted passport answers
`410 Gone` (the state is irreversible), while a withdrawn one stays `200`
because it can be reinstated. Both tombstones are now `private, no-store`
and `noindex`: the package has no cache purge hook, so a shared cache
must never hold a tombstone that a reinstatement would have to displace,
and the tombstone must not be indexed regardless of the channel's
`indexable` setting.

Tests: redactAll nulls superseded payloads and stamps the reason on
them; a version redacted individually earlier keeps its own reason; the
redacted route is 410 + no-store + noindex; the withdrawn route is 200 +
no-store + noindex.
Adds `GET /{prefix}/{uuid}/r/{sequence}/{locale}`: the state of the
passport as of one release, for one locale, resolved through
`PublicationRelease::versionsAsOf()`. This is the URL a printed carrier
can be bound to: it names one moment across every language and keeps
resolving to exactly that state after the passport moves on.

Semantics:
- strict locale, no Accept-Language negotiation; a locale that had no
  version yet at that release is 404 there
- 200 with a banner naming the release and whether it is still current,
  linking to the live page; the locale switcher stays inside the release
- never indexed (`noindex, noarchive, nofollow`, a `<link rel=canonical>`
  and a `Link` header pointing at the live page); no JSON-LD negotiation,
  since a historical payload's stamped identity is the publication URL
- a version redacted individually renders the tombstone with 410 and
  `no-store`, even while the publication itself stays Published; this
  also closes the same gap on the live route
- the ETag now covers release, currency and redaction state, because
  the banner and the tombstone change the HTML without changing the
  checksum; TEMPLATE_VERSION bumped
- release pages are not counted as views

`show()` and `showRelease()` share one `render()`. The four new strings
live in the Publication package and are present in all 33 locales, in
English where no translation exists yet, so the translation audit passes.
@midego1

midego1 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Context, motivation and suggested review order for this and the related PRs: #683

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant