Skip to content

feat(publication): carrier issuance bound to a release - #682

Open
midego1 wants to merge 5 commits into
unopim:3.xfrom
midego1:feat/publication-carrier-issuance
Open

midego1 wants to merge 5 commits into
unopim:3.xfrom
midego1:feat/publication-carrier-issuance

Conversation

@midego1

@midego1 midego1 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Stacked on #681 (which stacks on #679 and #680)

Only the last commit (feat(publication): carrier issuance bound to a release) is new here.

Problem

The public /carrier.svg encodes the live passport URL, and nothing records what was printed. A manual holds a QR code for years. When the passport moves on, there is no durable answer to "which code is in that manual, and what did it point at when it was issued". For the DPP that record is the evidence, not the image.

Change

publication_carrier_issuances: publication_id, release_id, target (the exact string encoded, kept verbatim so a later base-URL change cannot rewrite history), format, issued_at, issued_by_id. Immutable like versions and releases: update() / delete() throw ImmutableVersionException.

CarrierIssuer::issue() mints the row and derives the target from the per-channel passport base URL (general.publication.settings.base_url, falling back to app.url), the same base every other printed link already uses.

Public entry route GET /{prefix}/{uuid}/r/{sequence}: the URL a release carrier encodes. It negotiates the locale once from Accept-Language, among the locales that exist in that release, and 302s to the strict per-locale URL from #681. This mirrors how the live carrier's bare /{uuid} works. Negotiation moved into PublicationResolver::pickVersion() so the live and release paths share one implementation.

Admin: an "Issue QR code" action per release on the versions page (publish permission, ACL row added), returning the SVG as a download and recording the issuance. The page now also shows a Release column on versions, a Releases table (with the locales each release contains), and an Issued QR codes table with every issuance and its encoded link.

CarrierSvg: QR rendering extracted from PublicationCarrierController so the live carrier and an issued release carrier print identically for the same target. The live carrier's behaviour is unchanged.

Strings: 15 new keys under passport::app.publications.releases / .carrier, present in all 33 locales. unopim:translations:check passes. PHPStan (level 2) clean.

Tests

Publication:

  • issuance records the exact target <base>/p/{uuid}/r/1, release, format, issuer; update and delete throw
  • entry URL 302s to the strict per-locale URL with Vary: Accept-Language; unknown release 404s
  • Accept-Language is honoured only among locales present in that release, falling back to what exists

ProductPassport:

  • admin POST issues a carrier: 200, image/svg+xml, attachment filename, <svg body, row recorded with release and issuer
  • unknown release 404s and records nothing
  • versions page lists releases and the "Issue QR code" action, and lists the issuance with its encoded link after issuing

Not in this PR

  • Caching of the live /carrier.svg (s-maxage=86400 on a target that can change when the GTIN or base URL changes). Worth a small follow-up with an ETag on the target string.
  • Protecting publications.alias_identifier against being rewritten on a GTIN correction. Belongs with the GS1 qualifier work.

Related

Follows #677, #678, #679, #680, #681. Next: GS1 Digital Link qualifiers (/01/{gtin}/10/{lot}, /01/{gtin}/21/{serial}) resolving to a release through a lot→release mapping behind a contract.

…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

The public `/carrier.svg` encodes the live URL, and nothing records what
was printed. A manual holds a QR code for years; when the passport moves
on there is no durable answer to "which code is in that manual and what
did it point at".

Add `publication_carrier_issuances`: publication, release, the exact
string encoded, format, issued_at, issued_by. Immutable, like versions
and releases. `CarrierIssuer::issue()` mints the row and derives the
target from the per-channel passport base URL, the same base every
other printed link uses.

The target is a new public entry route, `/{prefix}/{uuid}/r/{sequence}`:
it negotiates the locale once from Accept-Language among the locales
that exist in that release and 302s to the strict per-locale URL, the
same way the live carrier's bare `/{uuid}` works. Negotiation is moved
into `PublicationResolver::pickVersion()` so the live and release paths
share it.

Admin: "Issue QR code" per release on the versions page (publish
rights), returning the SVG as a download and recording the issuance.
The page also lists releases (with their locales) and every issuance
with its encoded link. QR rendering is shared via `CarrierSvg` so the
public live carrier and an issued release carrier print identically for
the same target.
@midego1
midego1 force-pushed the feat/publication-carrier-issuance branch from 8bc8650 to 393e7ca Compare September 4, 2026 09:18
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.

2 participants