# GS1 conformance

What taproute implements, where the standard says so, and — just as importantly — what it does
not do. This is the document to hand a brand's compliance team or a GS1 Member Organisation.
Every claim below is one you can check in the code; the gaps are listed rather than omitted.

Standards targeted (currency last verified against ref.gs1.org on **2026-07-19** — every version
below was confirmed still-current from GS1's own documents on that date):

- **GS1-Conformant Resolver Standard 1.2.0 (Ratified Jan 2026)** —
  https://ref.gs1.org/standards/resolver/ — no 1.3 exists.
- **GS1 Digital Link Standard: URI Syntax 1.6.0 (Ratified Mar 2025)** —
  https://ref.gs1.org/standards/digital-link/ — no 1.7 exists; the legacy combined "GS1 Digital
  Link Standard 1.1.4" document GS1 reposted in Jan 2026 is housekeeping, explicitly marked "no
  further updates expected", not new normative content.
- **GS1 Syntax Dictionary** — https://github.com/gs1/gs1-syntax-dictionary — the machine-readable
  AI table GS1 actually maintains, and the source of truth we validate against. It is **vendored
  at `packages/db/src/gs1/vendor/gs1-syntax-dictionary.txt` and diffed on every test run**
  (`apps/dashboard/test/gs1-syntax-drift.test.ts`), so "derived from the dictionary" is a property the build
  checks rather than a sentence somebody wrote once and nobody re-checked. The diff covers AI
  coverage in both directions (541/541), the accepted value lengths, the FNC1 predefined-length
  flag on every AI, the set of Digital Link primary keys and their qualifier sets, each key's
  check-character scheme, and the GCP offset — the last of which is component-aware, because
  `gcppos1` on GRAI sits on the second component (`N1,zero N13,csum,gcppos1`) and a naive read
  puts a customer's licensed prefix in the wrong place. Latest release
  **2026-01-27**; our table was diffed against it on 2026-07-19: **in sync in both directions**,
  including the 2026 telecom AIs (8040 IMEI, 8041 IMEI2, 8042 ESIM, 8043 PSIM) and AI 717
  (NHRN Costa Rica). The one place the dictionary and the URI-syntax ABNF used to disagree —
  AI 8020 as a qualifier of AI 415, where the 1.4 ABNF said "no qualifier" and the dictionary
  said `req=8020` — **no longer exists**: 1.6 §8.5 corrected `refNo-comp` to be mandatory for
  `payTo-path`, which is what the dictionary said all along. We followed the dictionary through
  that disagreement and now follow both. The deviation is gone, and with it the leniency it
  justified: see "AI 415" below. The dictionary's 2025-01-30 release also removed **AI 8020 from
  the permitted *data attributes***: it is path-only, and we reject `?8020=` accordingly (see
  "Data attributes" below).
- **GS1 General Specifications, Release 26.0 (Ratified Jan 2026)** — §7.9 (check characters) and
  the retail-POS symbol specification tables for carrier sizing. Release 26.0 restructured the
  document: the 2D symbol spec tables now live in **§5.12.3** (Table 5-46 for QR carrying a
  Digital Link URI: X-dimension 0.396/0.495/0.990 mm, quiet zone 4X) — the numbers we implement
  are unchanged from prior releases.

Verified against the official test suite: https://ref.gs1.org/test-suites/resolver/ (suite
version 1.0.0; GS1 refreshed it 2026-06-29 — targets Resolver Standard 1.2.0, does not test
compression). **Last full run 2026-07-19, against the production resolver on the shared host:
27/27 pass, no warnings.** That run caught one real defect, since fixed and pinned by test: the
redirect `Location` was round-tripped through `new URL()`, so a destination stored as a bare
origin (`https://example.com`) redirected with a trailing slash the linkset did not carry, and
the suite's byte-comparison of Location against the linkset href flagged it. The resolver now
echoes the stored href byte-for-byte when there is no query string to merge.

Automated coverage: **every test in `apps/redirect/test/`**, run in CI on every change — a count is
not quoted here because two honest ways of counting one disagree, and a number in a document goes
stale the day after it is written. `conformance.test.ts` runs inside a real Workers runtime and
covers status codes, CORS, HEAD semantics, tenant isolation and the D1 fallback; the rest are unit
tests, including `schema.test.ts`, which
validates hand-built linksets and the resolver description file against GS1's **published JSON
schemas, vendored as fixtures** (not fetched at test time). `apps/redirect/test/keys.test.ts`
and `compression.test.ts` pin the spec conformance and the compression fixes described below.

## Primary keys

All sixteen keys the Digital Link syntax defines, with **every** qualifier of each — the
standard permits supporting a subset of keys but not a subset of a key ("for each supported
primary identification key, all its key qualifiers and data attributes SHALL be fully
supported"). The qualifier column is diffed against the dictionary's `dlpkey` field by a test.

| AI | Key | Qualifiers (`dlpkey`) | Check |
|----|-----|-----------------------|-------|
| 00 | SSCC | — | mod-10 |
| 01 | GTIN | 22, 10, 21 **or** 235 (TPX → UPUI) | mod-10, and only at the four issued lengths |
| 253 | GDTI | — | mod-10 on the 13-digit head |
| 255 | GCN | — | mod-10 on the 13-digit head |
| 401 | GINC | — | none defined |
| 402 | GSIN | — | mod-10 |
| 414 | GLN | 254 **or** 7040 (→ FID) | mod-10 |
| 415 | GLN (pay to) | 8020 — **mandatory**, see below | mod-10 |
| 417 | Party GLN | 7040 (→ EOID) | mod-10 |
| 8003 | GRAI | — | leading zero (a **pad character**, per 1.6 §8.4) + mod-10 on the 14-digit head |
| 8004 | GIAI | 7040 (→ MID) | none defined |
| 8006 | ITIP | 22, 10, 21 | mod-10 + piece ≤ total |
| 8010 | CPID | 8011 | none defined |
| 8013 | GMN | — | check character **pair** (base 82 → 32) |
| 8017 | GSRN-P | 8019 | mod-10 |
| 8018 | GSRN | 8019 | mod-10 |

The `or` is mutual exclusivity, and it is load-bearing: a UPUI is a GTIN *and* a TPX, and it
takes no lot or serial alongside them.

**AI 415 requires AI 8020.** URI Syntax 1.6 §8.5 corrected `refNo-comp` to be mandatory for
`payTo-path`, so a bare `/415/{gln}` is not a legal Digital Link URI and earns a **400**, not a 404.
Under 1.4 we accepted it: the ABNF and the Syntax Dictionary disagreed, we followed the dictionary,
and being lenient about the qualifier cost nothing. 1.6 settled that disagreement in the
dictionary's favour and made the leniency non-conformance, so it is gone. All sixteen keys stay
advertised in `/.well-known/gs1resolver` — the standard requires full support of any key you
advertise, and AI 415 *is* fully supported; a mandatory qualifier is its grammar, not a subset of
it. An invoicing party with no payment slip reference identifies nothing payable.

GTIN-8/12/13 are accepted and canonicalised to GTIN-14 for lookup, while the linkset anchor
echoes the key exactly as the client wrote it. The length it was **issued** at is stored
(`gs1_items.keyLength`), because padding is lossy: a GTIN-8 and a GTIN-12 whose company prefix
begins `0000` both pad to six leading zeros, and the GS1 prefix — hence which company prefix
licenses it — is read from different digits in each case.

**Data attributes**: the full GS1 Syntax Dictionary — all 541 AIs, verified two-way by test (no
AI missing, none invented, and the measurement families bounded to the decimal positions they
actually define, so AI 3106 is rejected). Any AI may be attached to a catalogue item as master
data, is validated against its published format, and is encoded into the query string of the
printed Digital Link URI — with one exception the dictionary itself makes: **AI 8020 is
path-only** (removed from the permitted data attributes in the dictionary's 2025-01-30 release),
so it is refused as master data and a `?8020=` query parameter is a 400 at the resolver.

Validation is **structural, not semantic**: length, character set, and format shape per the
published format strings, plus check digits wherever a scheme is defined. The dictionary's
per-component *linters* (calendar-valid dates for `yymmdd`, ISO 3166 country codes, ISO 4217
currencies, latitude/longitude ranges…) are a layer we do not replicate — `(11)999999` passes
here and would fail a full linter. Stated so the claim above is exactly as big as the code.

## Resolver behaviour

| Requirement | Status |
|---|---|
| `/.well-known/gs1resolver`, schema-valid, CORS-readable | ✅ advertises all 16 keys |
| Linkset as RFC 9264 JSON on `Accept: application/linkset+json` | ✅ |
| `?linkType=linkset` returns the linkset and does **not** redirect | ✅ JSON always, whoever asks |
| HTML representation with the linkset embedded as JSON-LD | ✅ `?linkType=all` from a browser (`SHOULD` in the standard) |
| `gs1:defaultLink` exactly once per anchor, href + title only | ✅ |
| Default link exists at the entry level or higher up the tree | ✅ enforced on **every** write; a linkset with no key-level link is rejected |
| Qualifier hierarchy walks **up** (unknown serial → lot → key) | ✅ |
| Requested `linkType` not registered → **404**, never the default (1.2.0 change) | ✅ |
| 400 on a syntactically invalid request URI | ✅ bad check characters, wrong-key qualifiers, non-issued GTIN lengths, ITIP piece > total |
| 404 when nothing is registered for a valid identifier | ✅ |
| 300 when several links are equally good | ✅ |
| Query string passed through to the destination | ✅ including attributes carried inside a compressed URI (`linkType`, `context` consumed per §2.12) |
| Content negotiation on Accept-Language / Accept | ✅ on the plain redirect too — a phone camera sends no `linkType` |
| Link header pointing at the JSON-LD context | ✅ |
| Compressed Digital Link URIs | ✅ fully and partly compressed, decompressed and resolved (`MAY` in the standard) |

## 2D carrier

- **Quiet zone: 4 modules** on all four sides, everywhere — the resolver's carrier endpoint, the
  short-link QR, and the styled designer. It is a single constant per renderer, not a margin
  recomputed at each call site: `GS1_QUIET_ZONE = 4` in `carrier.ts` is passed to the encoder,
  subtracted back out to report the module count, included in every printed size, and stated on the
  spec sheet, so the number in the artwork and the number in the paperwork cannot diverge. In the
  styled renderer (`@qr/code`) the quiet zone *is* the viewBox (`0 0 modules+8 modules+8`) rather
  than a rounded pixel margin, and a test pins that arithmetic at every symbol size.
  Scope of that check, stated exactly: it covers `@qr/code`. **The GS1 carrier's own quiet zone is
  single-sourced but not currently asserted by a test** — `carrier.test.ts` covers the canonical
  query ordering of the printed URI, not the symbol's border.
- **Error correction: level M** by default; Q and H are available on the API. GS1 does **not**
  mandate a level for a Digital Link QR — it says to pick one to suit the use case — so M is our
  engineering default, not a quoted requirement. L is never offered.
- **No logo overlay on a GS1 carrier.** The carrier generator has no image path at all.
- **A plain QR Code (`]Q1`), not a GS1 QR Code (`]Q3`).** A Digital Link URI is a URI; the
  FNC1/element-string symbology is for a different job — and it is the job the *linear* carrier
  does. A GS1-128 from this product announces `]C1` and carries the element string with its FNC1
  separators, which is where that symbology belongs. We still do not claim `]Q3`.
- **Print facts, not screen facts.** Every symbol reports its module count *and* its physical
  size: at the target X-dimension of 0.495 mm, a 33-module symbol prints 20.3 mm square. The
  carrier tab and the print sheet both state the X-dimension, the size, ECC, and "print at 100%,
  do not scale or crop". The print sheet sizes each symbol in millimetres, so the same sheet
  prints the same physical code on A4 and on Letter. The API says the same thing in headers
  (`X-Gs1-Width-Mm`, `X-Gs1-X-Dimension-Mm`, `X-Gs1-Modules`, `X-Gs1-Ecc`), so an integration is
  told how big a symbol must print rather than discovering it at the press.
- **A placement spec sheet** (`/api/gs1/[id]/spec-sheet`) for the packaging designer or print shop:
  the symbol, its URI and element string, and the print width at the **minimum, target and maximum**
  X-dimension, so the numbers that decide whether a code scans at a till travel with the artwork.
  The dual-marking distance is cited to GS1's "2D Barcodes at Retail Point-of-Sale Implementation
  Guideline" §4.1.2 (within 50 mm of the linear barcode's centre), and the sheet points at General
  Specifications §4.15 for the full multi-barcode rules rather than paraphrasing figures we have not
  verified. Every number on it comes from the carrier module, not from copy.
- **Human-readable interpretation** (the key, under the symbol) on the print sheet.

### Compression

Compressed URIs are produced by GS1's own reference implementation (vendored, Apache-2.0,
`packages/db/src/gs1/vendor/`), with three corrections:

1. **A precision bug in the reference implementation.** It round-tripped the key through
   `parseInt()`, a float above 2^53, so every 17- and 18-digit key (SSCC, GSIN, ITIP, GSRN)
   compressed to a URI encoding a *different identifier* — silently. Moved to `BigInt`.
2. **A GRAI check-digit bug in the reference implementation.** It verified AI 8003's check digit at
   position 13, while its own `format` (`N14+X..16`) and its own `regex` (`(\d{14})`) both put the
   numeric head at fourteen digits — every sibling key agrees with its own head (GDTI and GCN are
   `N13` with "13"; ITIP is `N14` with "14"), GRAI alone did not. So the toolkit **threw on valid
   GRAIs** and would pass a value whose real check digit is wrong. This is the pre-1.5 defect URI
   Syntax 1.6 §8.4 records as corrected ("ABNF syntax for GRAI (AI 8003) corrected… use of `0` as
   pad character, not as part of GRAI value"). Patched to 14, pinned in both directions by test.
   Our own parser (`keys.ts`) never had this bug and validated GRAI correctly throughout; the
   toolkit is only reached via `decompressPath`.
3. **Every compressed URI is decompressed again and compared to its input before it is
   returned.** If the round trip does not hold, the uncompressed URI is printed instead:
   longer, and correct. A wrong identifier on a pack cannot be recalled.

**Known limitation.** The vendored toolkit's optimisation table predates AI 235, AI 7040, AI 417
and GMN-as-a-key, and — confirmed separately, not previously documented — has no entry for GRAI
either. Compressed URIs for those keys cannot be produced (we fall back to the uncompressed URI,
which is correct) and a compressed URI for one of them minted by a modern encoder will not
resolve. Every other key compresses and resolves. Re-verified 2026-07-16 against GS1's current
`gs1/GS1DigitalLinkToolkit.js` master branch: GS1 has not added these to their own reference
implementation either, so this is a gap in the upstream toolkit, not staleness in our copy.

The vendored file itself was refreshed from upstream on 2026-07-16 (previously vendored without a
tracked source commit). That refresh fixed a latent bug found in the process: every `X..n`
(character set 82) field's internal validation regex was narrowed to `\x30-\x39` (digits only)
instead of the correct `\x30-\x3F` (digits plus `:;<=>?`), silently blocking compression — not
resolution — of any lot, serial, or similar value containing one of those four characters. The
BigInt precision patch (below) was reapplied and reverified line-by-line against the fresh copy; the
`apps/redirect` suite passes unchanged.

### Not supported

- **Data Matrix.** GS1 permits a Digital Link URI in Data Matrix, and healthcare largely uses
  it. We emit QR, GS1-128, EAN-13, UPC-A, EAN-8 and ITF-14; not Data Matrix.
- **ISO 15415 verification grading.** We give you the geometry; we do not grade a printed
  sample. Since the printed-code analyzer (`/app/gs1/analyze`) shipped we *read* printed
  samples — and that line still holds, deliberately. Every one of 15415's eight parameters is
  defined against a calibrated measurement: specified illumination geometry, 660 nm, an aperture
  sized to the symbol's own X-dimension, reflectance against a calibrated white standard, on an
  instrument conforming to ISO/IEC 15426-2. A phone photograph has none of those conditions —
  exposure, white balance and tone curve are the handset's choices, HDR tone mapping is
  spatially varying, unsharp masking manufactures edge contrast that is not on the paper, and a
  colour sensor cannot predict what a 660 nm scanner sees. We could compute a contrast number
  from those pixels. It would be wrong in the FLATTERING direction, which is the one direction a
  compliance tool may not err in, so we do not compute it. The analyzer says so in a block it
  always renders, and its headline badge never goes green. What it does report is a read-back:
  symbology, version, correction level, unused error-correction margin, mirroring, reflectance
  reversal, quiet-zone sampling, and — only when the operator states the printed width with a
  ruler — an X-dimension against Table 5-46. Each finding is labelled with whether it is a
  property of the artwork or of that particular photograph. Print conformance is refused
  outright for linear symbologies — including for the linear symbols we now generate ourselves.
  The reason changed when generation shipped, and it is worth being precise about why. It used to
  be arithmetic: the only X-dimension range we had was 0.396–0.990 mm, written for a 2D carrier,
  and applying it to a 0.330 mm nominal EAN-13 would have failed a conformant symbol. That is no
  longer true, because generating linear symbols required per-symbology ranges and we now have
  them. What survives is stronger: a linear symbol has **no error correction**, so its entire
  margin for error is its bar widths, and bar width reduction and print gain are precisely the
  quantities a photograph cannot measure. Knowing the X-dimension we *drew* at is not knowing what
  came off the press.
- **Context-based link selection.** `?context=` is accepted and consumed rather than passed
  through to the destination, which is what §2.12 asks for — but it does **not** influence which
  link is chosen. A link registered for one context is served to every context. The parameter is
  handled; the feature behind it is not implemented.
- **`gs1:defaultLinkMulti`.** Defined in the link-type vocabulary and correctly marked
  resolver-derived, but no code path emits one. Only `gs1:defaultLink` is ever produced.

## Licensing — the part no standard enforces

A Digital Link asserts that the identifier inside it was licensed to the publisher by GS1.
Nothing in the resolver standard checks that, and a self-serve platform that skips it will
happily let a stranger publish another brand's GTIN.

- A workspace records the **GS1 Company Prefix** it licensed (`/app/gs1/licences`).
- Every key is matched against those prefixes. **The licence is checked before every publishing
  action, on every origin** — the shared host *and* a custom domain. Owning a domain is not
  permission to print someone else's GTIN on it.
- Prefixes are checked for **overlap**, not just equality: a workspace holding `0614141` owns key
  space that a four-digit `0614` would swallow, and two workspaces cannot both hold it.
- **Restricted-circulation prefixes are rejected outright**: 02x/04x/2xx (company- and
  store-internal), coupon ranges, refund receipts, Bookland. These are the most common self-serve
  mistake — an internal "GTIN" that is meaningless outside one retailer.
- A **draft takes no claim.** Claiming on create would let anyone lock a competitor out of an
  identifier without ever publishing anything.
- Assurance is stated honestly. With registry credentials configured (`GS1_REGISTRY_URL` /
  `GS1_REGISTRY_KEY`, e.g. Verified by GS1) a licence is **verified**. Without them it is
  **attested** — the user's declaration, stored with the user id, IP and timestamp, which is what
  we would produce in a dispute.

## Serialisation

Serials are **derived, not stored**: each carries its batch prefix, its index, and a truncated
HMAC over both under the batch's secret seed. So a batch of 100,000 costs no rows, the CSV and
the print sheet reproduce the *same* serials, a lost export can be re-derived from its job id,
and `GET /api/gs1/verify?serial=…` answers "did we issue this?" by recomputation. A counterfeiter
cannot mint a serial that passes without the seed.

Verification is reachable where the checking actually happens, not only from a browser: the same
answer is served over a bearer key at `GET /api/v1/gs1/verify`, which is what a line scanner or a
returns desk calls, and as an MCP tool. A serial that checks out raises a `gs1.serial.verified`
webhook, so an automation can act on it. The check is workspace-scoped — it answers "did *you*
issue this?" — and a `false` is not by itself proof of a fake, only the first thing worth looking at.

What this is **not**: an EPCIS event trail. We can tell you a serial was issued; we cannot yet
tell you where it has been.

## What this is not

- **Not a GS1 certification.** GS1 does not certify resolvers. Conformance is self-declared
  through `/.well-known/gs1resolver` plus the public test suite, both of which we pass.
- **Not GS1 US Solution Partner membership.** That is a commercial programme with GS1 US; the
  engineering bar above is separate from, and a prerequisite for, that conversation.
- **The GCP Length Table is not bundled.** It is 14 MB and GS1 password-protects it. Licence
  matching uses the workspace's declared prefix, which is what the licence is granted on.
