From 688d6c23ee4eaf153b2524607f968db9d61bfa22 Mon Sep 17 00:00:00 2001 From: Mike Stankavich Date: Wed, 4 Nov 2026 06:08:06 -0600 Subject: [PATCH 1/5] docs: publish the tag numbering and encoding page Bureaus, partners and RFQs need one public reference for how TrakRF numbers the tags it supplies: the 96-bit GID-96 EPC layout, bureau encoding instructions, label rules and first-article checks. Lives under Integrations at /docs/integrations/tag-numbering. The app section describes today's behaviour (full EPC shown); prefix trimming to the printed 8 digits is marked as planned. Refs: TRA-1305 Co-Authored-By: Claude Opus 5.5 --- docs/integrations/index.md | 2 + docs/integrations/tag-numbering.md | 140 +++++++++++++++++++++++++++++ sidebars.ts | 1 + 3 files changed, 143 insertions(+) create mode 100644 docs/integrations/tag-numbering.md diff --git a/docs/integrations/index.md b/docs/integrations/index.md index 557c5f2..d530935 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -16,4 +16,6 @@ None of the integration surfaces below are documented yet for self-service. Earl ## What's documented today +**[Tag numbering and encoding](./tag-numbering)** — how TrakRF numbers the tags it supplies: the EPC layout, encoding instructions for print bureaus, label rules, and first-article checks. + For REST API integration (the primary customer-facing integration surface), start at **[Getting started → Using the API](/docs/getting-started/api)** and the **[interactive API reference](/api)**. diff --git a/docs/integrations/tag-numbering.md b/docs/integrations/tag-numbering.md new file mode 100644 index 0000000..a29b99b --- /dev/null +++ b/docs/integrations/tag-numbering.md @@ -0,0 +1,140 @@ +--- +sidebar_position: 4 +title: Tag numbering and encoding +description: How TrakRF numbers the RFID tags it supplies — the 96-bit EPC layout, encoding instructions for print bureaus, label rules, and first-article acceptance checks. +--- + +# Tag numbering and encoding + +This page is the reference for how TrakRF numbers the RFID tags it supplies. It is written for +print and encoding bureaus, partners, and anyone receiving TrakRF-encoded tags. + +## Summary + +Every TrakRF-encoded tag carries one number, written two ways: + +- **On the label**: an 8-digit sequence number, for example `31570344`. +- **In the chip**: a 96-bit EPC made of a fixed 12-character prefix, four reserved zeros, and the + same 8 digits. + +| Printed on the label | Encoded in the chip (EPC, hex) | +| -------------------- | ------------------------------ | +| `31570344` | `355E4ACFD000000031570344` | + +The last 8 characters of the EPC are always the printed number. The sequence digits are written +into the EPC as decimal digits, one digit per hex character, so nothing after the prefix is ever +`A`–`F`. + +## EPC layout + +The EPC is 24 hex characters (96 bits). Positions count from the left, starting at 1. + +| Characters | Field | Value | +| ---------- | ----------------------------- | --------------------------------------- | +| 1–2 | Header | `35` (GID-96) | +| 3–9 | General Manager Number | `5E4ACFD` (98872573 decimal) | +| 10–12 | Object class, high characters | `000` | +| 13–16 | Reserved | `0000` | +| 17–24 | Sequence | 8 decimal digits, `10000001`–`99999999` | + +Characters 1–12, `355E4ACFD000`, are the fixed TrakRF prefix. Characters 13–16 are always `0000`. + +### How the numbers are issued + +- Sequence numbers come from one global counter, starting at `10000001`. A number is never + reused. +- One pool covers every customer, order, and demo roll. The EPC carries no customer code. +- Each order is allocated a contiguous block: the ordered quantity plus a 5% overrun buffer. + Blocks needn't start or end on round numbers. Buffer numbers that go unused are retired, not + reissued. +- Within a roll, tags run from the lowest number to the highest. + +### What a standard decoder shows + +The layout is a valid GID-96 encoding, so standard EPC decoders read it. For the example above +they show: + +```text +urn:epc:id:gid:98872573.0.827786052 +``` + +The serial a decoder shows (`827786052`) is not the printed number. GID-96 reads the last 9 hex +characters (`031570344`) as one binary value. The printed number is those same characters read +as decimal digits. Match tags to labels on the last 8 characters of the EPC, never on the +decoded serial. + +## Encoding instructions for bureaus + +TrakRF supplies a data file with one row per tag. Encode from that file only. + +- Write the **EPC bank (MB01)** with the `epc_hex` value from each row. +- **96 bits**, with the EPC length in the PC word set to **6 words** (PC `3000` hex when no other + PC flags are set). +- **No lock and no access password.** +- **Don't use a start-and-increment rule.** Encoder software counts in binary and would produce + `A`–`F` characters. Take each EPC from its row. +- **On an encode failure, re-encode the same row on the next good tag.** Never skip a number. + +### File columns + +```text +order,line,roll,tag_type,sequence,epc_hex,barcode,human_readable,overrun +``` + +Rows with `overrun=yes` are the buffer. Encode in file order and use buffer rows only to cover +overruns. Report the last number used back to TrakRF. + +## Label rules + +| Element | Content | +| ------------------- | ----------------------------------------------------------- | +| Human-readable text | The 8-digit sequence | +| Barcode | Code 128, holding the same 8 digits | +| QR code | Only when the order asks for one: the full 24-character EPC | +| Header text | Specified per order | + +## First-article acceptance checks + +Before a full run, check a first article against all of these: + +1. The EPC decodes as GID-96 with General Manager Number `98872573`. +2. The last 8 characters of the EPC equal the printed number. +3. The barcode scans to the same 8 digits. +4. No two tags carry the same EPC. + +Worked through for the example tag: + +| Check | Result | +| ------------------ | ------------------------------------------------------------------ | +| Decodes as GID-96 | `355E4ACFD000000031570344` → `urn:epc:id:gid:98872573.0.827786052` | +| Last 8 = printed | `…31570344` = `31570344` | +| Barcode = 8 digits | Code 128 `31570344` | +| No duplicates | Checked across the whole run, not per tag | + +## How TrakRF shows tag IDs + +TrakRF stores and matches on the full EPC. Today the app shows a TrakRF tag as its full EPC, for +example `355E4ACFD000000031570344`, and you find a tag by its full EPC — for instance by pasting +it into [Locate](/docs/app-tour/locate). The app trims leading zeros from tag values, which +doesn't change a TrakRF EPC because it starts with `3`. + +**Planned:** a per-organization prefix list, with `355E4ACFD000` as the default. When an EPC +starts with a listed prefix, the app removes the prefix and strips leading zeros for display, so a +TrakRF tag shows as its printed 8 digits. Searching by those 8 digits or by the full EPC will find +the same tag. This page will be updated when that ships. + +## Customer-specified encodings + +When a customer supplies its own numbering scheme, such as sequential hex, TrakRF encodes it as +given. Those tags are outside this scheme: they don't use the TrakRF prefix or the sequence pool, +and the app shows them as their full EPC. + +## Status and caveats + +- **`98872573` is a General Manager Number TrakRF assigned itself.** It was not issued by GS1. +- **The scheme is for closed-loop use.** Don't publish these IDs into open GS1 or EPCIS supply + chains. +- **A move to a RAIN CIN or GS1 prefix is planned.** The sequence pool stays global across any + prefix change, so an 8-digit number never exists under two prefixes. + +Questions about an order or a data file: [email support](mailto:support@trakrf.id). diff --git a/sidebars.ts b/sidebars.ts index 6f492c7..83d2109 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -75,6 +75,7 @@ const sidebars: SidebarsConfig = { items: [ "integrations/mqtt-message-format", "integrations/fixed-reader-setup", + "integrations/tag-numbering", ], }, ], From dd9f896a193864a1e1b008fb1e73fa10864ff587 Mon Sep 17 00:00:00 2001 From: Mike Stankavich Date: Wed, 4 Nov 2026 06:30:46 -0600 Subject: [PATCH 2/5] test: decode every example EPC on the tag numbering page in lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bureaus encode from that page, so an example URN or field must be decoded by a script, not checked by eye. Every 24-hex value must be GID-96 under manager 98872573, every EPC → URN pair must decode to the stated URN, and standard-pool EPCs must carry 0000 and an 8-digit decimal sequence. Refs: TRA-1305 Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- scripts/check-tag-numbering.mjs | 86 +++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 scripts/check-tag-numbering.mjs diff --git a/package.json b/package.json index 53a343f..7fef1ce 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "docusaurus build", "serve": "docusaurus serve --host 0.0.0.0", "typecheck": "tsc", - "lint": "prettier --check . && node scripts/check-contributing.mjs", + "lint": "prettier --check . && node scripts/check-contributing.mjs && node scripts/check-tag-numbering.mjs", "lint:fix": "prettier --write .", "clear": "docusaurus clear", "swizzle": "docusaurus swizzle", diff --git a/scripts/check-tag-numbering.mjs b/scripts/check-tag-numbering.mjs new file mode 100644 index 0000000..1f562bd --- /dev/null +++ b/scripts/check-tag-numbering.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// Decodes every example EPC on the tag numbering page, so an example is never +// checked by eye. Bureaus encode from this page, and a wrong URN or a +// mis-laid field there ends up in the chip. Run from `pnpm lint`. +// +// Rules: +// - every 24-hex-character value is a GID-96 EPC under manager 98872573 +// - an EPC followed by a URN on one line (arrow or table row) decodes to it +// - every URN on the page is the decoding of some EPC on the page +// - a standard-pool EPC (namespace 000) has 0000 reserved and an 8-digit +// decimal sequence, 10000001–99999999 + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const pagePath = join(repoRoot, "docs/integrations/tag-numbering.md"); +const page = readFileSync(pagePath, "utf8"); + +const MANAGER = 98872573n; +const EPC_RE = /(?> 88n; + const manager = (v >> 60n) & ((1n << 28n) - 1n); + const objectClass = (v >> 36n) & ((1n << 24n) - 1n); + const serial = v & ((1n << 36n) - 1n); + return { + header, + manager, + urn: `urn:epc:id:gid:${manager}.${objectClass}.${serial}`, + }; +} + +const errors = []; +const epcs = [...new Set(page.match(EPC_RE) ?? [])]; +if (epcs.length === 0) errors.push("no example EPC found on the page"); + +const decoded = new Set(); +for (const epc of epcs) { + if (epc !== epc.toUpperCase()) + errors.push(`${epc}: write EPCs in uppercase hex`); + const d = decode(epc); + decoded.add(d.urn); + if (d.header !== 0x35n) errors.push(`${epc}: header is not 35 (GID-96)`); + if (d.manager !== MANAGER) + errors.push(`${epc}: manager number is ${d.manager}, not ${MANAGER}`); + if (epc.slice(9, 12) === "000") { + if (epc.slice(12, 16) !== "0000") + errors.push( + `${epc}: standard pool, reserved characters 13–16 are not 0000`, + ); + if (!/^[1-9][0-9]{7}$/.test(epc.slice(16))) + errors.push( + `${epc}: standard pool, sequence is not 8 decimal digits from 10000001`, + ); + } +} + +for (const [i, line] of page.split("\n").entries()) { + const pair = /([0-9A-F]{24})[^0-9A-Za-z]*(urn:epc:id:gid:\d+\.\d+\.\d+)/g; + for (const [, epc, urn] of line.matchAll(pair)) { + const want = decode(epc).urn; + if (urn !== want) + errors.push(`line ${i + 1}: ${epc} decodes to ${want}, page says ${urn}`); + } +} + +for (const urn of new Set(page.match(URN_RE) ?? [])) { + if (!decoded.has(urn)) + errors.push(`${urn}: not the decoding of any EPC on the page`); +} + +if (errors.length) { + console.error( + `docs/integrations/tag-numbering.md: ${errors.length} problem(s)`, + ); + for (const e of errors) console.error(` - ${e}`); + process.exit(1); +} +console.log( + `tag-numbering.md: ${epcs.length} example EPC(s) decode as stated.`, +); From 1e45796db7815ac5eaf6105ba3255308d07f8fdb Mon Sep 17 00:00:00 2001 From: Mike Stankavich Date: Wed, 4 Nov 2026 06:31:55 -0600 Subject: [PATCH 3/5] docs: add customer namespaces and scope bureau rules to the standard pool Review of the preview page: characters 10-12 are a namespace code (000 = standard pool, 001-FFF = customer namespaces), so the layout, issuance and bureau sections are scoped to the standard pool. The no-increment rule applies only to the decimal standard pool; customer namespaces may use start + hex increment. Adds the encode log, splits acceptance into first-article and post-run checks, and replaces customer-specified encodings with the namespace policy. Refs: TRA-1305 Co-Authored-By: Claude Opus 5.5 --- docs/integrations/tag-numbering.md | 137 ++++++++++++++++++++--------- 1 file changed, 95 insertions(+), 42 deletions(-) diff --git a/docs/integrations/tag-numbering.md b/docs/integrations/tag-numbering.md index a29b99b..9eeae2f 100644 --- a/docs/integrations/tag-numbering.md +++ b/docs/integrations/tag-numbering.md @@ -1,7 +1,7 @@ --- sidebar_position: 4 title: Tag numbering and encoding -description: How TrakRF numbers the RFID tags it supplies — the 96-bit EPC layout, encoding instructions for print bureaus, label rules, and first-article acceptance checks. +description: How TrakRF numbers the RFID tags it supplies — the 96-bit EPC layout, customer namespaces, encoding instructions for print bureaus, label rules, and acceptance checks. --- # Tag numbering and encoding @@ -11,7 +11,7 @@ print and encoding bureaus, partners, and anyone receiving TrakRF-encoded tags. ## Summary -Every TrakRF-encoded tag carries one number, written two ways: +A tag numbered from TrakRF's standard pool carries one number, written two ways: - **On the label**: an 8-digit sequence number, for example `31570344`. - **In the chip**: a 96-bit EPC made of a fixed 12-character prefix, four reserved zeros, and the @@ -25,28 +25,36 @@ The last 8 characters of the EPC are always the printed number. The sequence dig into the EPC as decimal digits, one digit per hex character, so nothing after the prefix is ever `A`–`F`. +Customers with their own numbering get a [customer namespace](#customer-namespaces) instead. + ## EPC layout The EPC is 24 hex characters (96 bits). Positions count from the left, starting at 1. -| Characters | Field | Value | -| ---------- | ----------------------------- | --------------------------------------- | -| 1–2 | Header | `35` (GID-96) | -| 3–9 | General Manager Number | `5E4ACFD` (98872573 decimal) | -| 10–12 | Object class, high characters | `000` | -| 13–16 | Reserved | `0000` | -| 17–24 | Sequence | 8 decimal digits, `10000001`–`99999999` | +| Characters | Field | Value | +| ---------- | ------------------------ | --------------------------------------------------------------- | +| 1–2 | Header | `35` (GID-96) | +| 3–9 | General Manager Number | `5E4ACFD` (98872573 decimal) | +| 10–12 | Namespace code | `000` = TrakRF standard pool; `001`–`FFF` = customer namespaces | +| 13–16 | Reserved (standard pool) | `0000` | +| 17–24 | Sequence (standard pool) | 8 decimal digits, `10000001`–`99999999` | + +In GID-96 terms, characters 10–15 are the Object Class field and characters 16–24 are the serial +field. The namespace code occupies the top of the Object Class. -Characters 1–12, `355E4ACFD000`, are the fixed TrakRF prefix. Characters 13–16 are always `0000`. +Characters 1–12, `355E4ACFD000`, are the standard-pool prefix. A customer namespace replaces `000` +with its own code. ### How the numbers are issued - Sequence numbers come from one global counter, starting at `10000001`. A number is never reused. -- One pool covers every customer, order, and demo roll. The EPC carries no customer code. -- Each order is allocated a contiguous block: the ordered quantity plus a 5% overrun buffer. - Blocks needn't start or end on round numbers. Buffer numbers that go unused are retired, not - reissued. +- The standard pool (namespace `000`) covers TrakRF's own numbering for every customer, order, and + demo roll, with no customer code in the EPC. Customers with their own numbering use a + [customer namespace](#customer-namespaces). +- Each order is allocated a contiguous block: the ordered quantity plus an overrun buffer (5% by + default, adjusted to the bureau's stated tolerance). Blocks needn't start or end on round + numbers. Buffer numbers that go unused are retired, not reissued. - Within a roll, tags run from the lowest number to the highest. ### What a standard decoder shows @@ -63,19 +71,59 @@ characters (`031570344`) as one binary value. The printed number is those same c as decimal digits. Match tags to labels on the last 8 characters of the EPC, never on the decoded serial. +## Customer namespaces + +A customer namespace prefix is `355E4ACFD` plus a 3-character namespace code, `001`–`FFF`. The +customer controls characters 13–24 (48 bits) and can use any sequencing in them: hex or decimal, +starting from zero or one. + +For example, with the illustrative namespace code `0A5` (not a real customer) and a one-based hex +sequence: + +| EPC | Standard decoder shows | +| -------------------------- | ---------------------------------- | +| `355E4ACFD0A5000000000001` | `urn:epc:id:gid:98872573.675840.1` | +| `355E4ACFD0A5000000000002` | `urn:epc:id:gid:98872573.675840.2` | + +A standard decoder shows the Object Class as the namespace code × 4096 plus the first 3 characters +of the customer's 12. That's expected, for example: + +```text +355E4ACFD0A5A00000000001 → urn:epc:id:gid:98872573.678400.1 +``` + +Match on the EPC characters, not the decoded fields. + +- **Customer-required rightmost characters**: when a customer's systems key on their last N + characters, those characters are encoded unchanged at the right. Any padding goes between the + prefix and their value, never inside it. +- **No all-zero prefixes by default.** Zero-padded EPCs collide with other vendors' tags and + clutter shared reader fields, so for a customer with its own numbering TrakRF proposes a + namespace. A zero-padded or other customer-mandated prefix is used only when the customer + explicitly requires it. Those tags fall outside both the standard pool and the namespaces. +- **Codes are never reassigned.** TrakRF records each namespace code in its own register and + doesn't reassign it, even after a customer leaves. + ## Encoding instructions for bureaus -TrakRF supplies a data file with one row per tag. Encode from that file only. +### All orders -- Write the **EPC bank (MB01)** with the `epc_hex` value from each row. -- **96 bits**, with the EPC length in the PC word set to **6 words** (PC `3000` hex when no other - PC flags are set). +- Write the **EPC bank (MB01)**: a **96-bit EPC**, with the EPC length in the PC word set to + **6 words**. Leave the other PC bits at the chip default. - **No lock and no access password.** -- **Don't use a start-and-increment rule.** Encoder software counts in binary and would produce - `A`–`F` characters. Take each EPC from its row. -- **On an encode failure, re-encode the same row on the next good tag.** Never skip a number. +- **On an encode failure, re-encode the same value on the next good tag.** Never skip a number. +- **Return an encode log for every run**, with one row per encoded tag: EPC and TID, plus the + data-file row number where there is a data file. + +### Standard pool (namespace `000`) + +TrakRF supplies a data file with one row per tag. Encode from that file only, writing the +`epc_hex` value from each row. -### File columns +**Don't use a start-and-increment rule.** The sequence is stored as decimal digits, and encoder +software counts in binary, so it would produce `A`–`F` characters. Take each EPC from its row. + +The file columns are: ```text order,line,roll,tag_type,sequence,epc_hex,barcode,human_readable,overrun @@ -84,32 +132,41 @@ order,line,roll,tag_type,sequence,epc_hex,barcode,human_readable,overrun Rows with `overrun=yes` are the buffer. Encode in file order and use buffer rows only to cover overruns. Report the last number used back to TrakRF. +### Customer namespace + +The order specifies either a data file, or a start value with a hex increment. + ## Label rules -| Element | Content | -| ------------------- | ----------------------------------------------------------- | -| Human-readable text | The 8-digit sequence | -| Barcode | Code 128, holding the same 8 digits | -| QR code | Only when the order asks for one: the full 24-character EPC | -| Header text | Specified per order | +| Element | Content | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| Human-readable text | The 8-digit sequence | +| Barcode | Code 128, holding the same 8 digits | +| QR code | Only when the order asks for one: the full 24-character EPC | +| Header text | Specified per order | +| Customer namespace order | Human-readable, barcode and QR content as specified per order — typically the customer's required characters, not the full EPC | -## First-article acceptance checks +## Acceptance checks -Before a full run, check a first article against all of these: +### First article, before the full run 1. The EPC decodes as GID-96 with General Manager Number `98872573`. -2. The last 8 characters of the EPC equal the printed number. +2. The last 8 characters of the EPC equal the printed number. For a customer namespace, the + customer's required rightmost characters equal the printed value. 3. The barcode scans to the same 8 digits. -4. No two tags carry the same EPC. -Worked through for the example tag: +Worked through for the standard-pool example: | Check | Result | | ------------------ | ------------------------------------------------------------------ | | Decodes as GID-96 | `355E4ACFD000000031570344` → `urn:epc:id:gid:98872573.0.827786052` | | Last 8 = printed | `…31570344` = `31570344` | | Barcode = 8 digits | Code 128 `31570344` | -| No duplicates | Checked across the whole run, not per tag | + +### After the run, from the encode log + +- No two tags in the run carry the same EPC. +- The last number used is reported to TrakRF. ## How TrakRF shows tag IDs @@ -120,14 +177,10 @@ doesn't change a TrakRF EPC because it starts with `3`. **Planned:** a per-organization prefix list, with `355E4ACFD000` as the default. When an EPC starts with a listed prefix, the app removes the prefix and strips leading zeros for display, so a -TrakRF tag shows as its printed 8 digits. Searching by those 8 digits or by the full EPC will find -the same tag. This page will be updated when that ships. - -## Customer-specified encodings - -When a customer supplies its own numbering scheme, such as sequential hex, TrakRF encodes it as -given. Those tags are outside this scheme: they don't use the TrakRF prefix or the sequence pool, -and the app shows them as their full EPC. +standard-pool tag shows as its printed 8 digits. Searching by those 8 digits or by the full EPC +will find the same tag. A customer namespace prefix is added to that organization's list, so its +tags show as the customer's characters with leading zeros stripped. This page will be updated +when that ships. ## Status and caveats From d0a49f9040b8d1c8b0a111c9add874fd9519db0e Mon Sep 17 00:00:00 2001 From: Mike Stankavich Date: Wed, 4 Nov 2026 07:11:48 -0600 Subject: [PATCH 4/5] docs: tag numbering wording fixes from the follow-up review Decoder note reads the tail characters as hex; first-article barcode check and the label table's first three rows are scoped to the standard pool; post-run check says the allocation register makes cross-run duplicates impossible for standard-pool blocks. Refs: TRA-1305 Co-Authored-By: Claude Opus 5.5 --- docs/integrations/tag-numbering.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/integrations/tag-numbering.md b/docs/integrations/tag-numbering.md index 9eeae2f..6943d55 100644 --- a/docs/integrations/tag-numbering.md +++ b/docs/integrations/tag-numbering.md @@ -85,8 +85,8 @@ sequence: | `355E4ACFD0A5000000000001` | `urn:epc:id:gid:98872573.675840.1` | | `355E4ACFD0A5000000000002` | `urn:epc:id:gid:98872573.675840.2` | -A standard decoder shows the Object Class as the namespace code × 4096 plus the first 3 characters -of the customer's 12. That's expected, for example: +A standard decoder shows the Object Class as the namespace code × 4096 plus the value of the first +3 characters of the customer's tail, read as hex. That's expected, for example: ```text 355E4ACFD0A5A00000000001 → urn:epc:id:gid:98872573.678400.1 @@ -138,13 +138,13 @@ The order specifies either a data file, or a start value with a hex increment. ## Label rules -| Element | Content | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | -| Human-readable text | The 8-digit sequence | -| Barcode | Code 128, holding the same 8 digits | -| QR code | Only when the order asks for one: the full 24-character EPC | -| Header text | Specified per order | -| Customer namespace order | Human-readable, barcode and QR content as specified per order — typically the customer's required characters, not the full EPC | +| Element | Content | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Standard pool: human-readable text | The 8-digit sequence | +| Standard pool: barcode | Code 128, holding the same 8 digits | +| Standard pool: QR code | Only when the order asks for one: the full 24-character EPC | +| Header text | Specified per order | +| Customer namespace order | Human-readable, barcode and QR content as specified per order — typically the customer's required characters, not the full EPC | ## Acceptance checks @@ -153,7 +153,8 @@ The order specifies either a data file, or a start value with a hex increment. 1. The EPC decodes as GID-96 with General Manager Number `98872573`. 2. The last 8 characters of the EPC equal the printed number. For a customer namespace, the customer's required rightmost characters equal the printed value. -3. The barcode scans to the same 8 digits. +3. The barcode scans to the same 8 digits. For a customer namespace, it scans to the customer's + printed value. Worked through for the standard-pool example: @@ -165,7 +166,9 @@ Worked through for the standard-pool example: ### After the run, from the encode log -- No two tags in the run carry the same EPC. +- No two tags in the run carry the same EPC. Across runs, the bureau doesn't need to check against + earlier orders: TrakRF's allocation register draws every standard-pool block once, so no number is + issued twice. - The last number used is reported to TrakRF. ## How TrakRF shows tag IDs From afa13f84127dd6482e4aba5123c1afcc0d2d1010 Mon Sep 17 00:00:00 2001 From: Mike Stankavich Date: Wed, 4 Nov 2026 07:32:41 -0600 Subject: [PATCH 5/5] feat: public tag allocation register, allocator script, and lint check Tag-number uniqueness rests on one register every block is drawn from; it lived only in a chat session. It now lives in data/tag-allocations/ as one row per standard-pool block and one per namespace code, with no customer names. scripts/allocate-tags.mjs allocates, exports the per-tag bureau CSV, and closes blocks; check-tag-allocations.mjs runs in pnpm lint and rejects gaps, overlaps, bad sizes and a register page that does not match the data. The page is rendered by the script and committed rather than generated during the build, so it exists under pnpm dev and its Markdown mirror carries real tables. Refs: TRA-1308 Co-Authored-By: Claude Opus 5.5 --- data/tag-allocations/blocks.csv | 5 + data/tag-allocations/namespaces.csv | 4 + docs/integrations/tag-allocations.md | 35 ++++ docs/integrations/tag-numbering.md | 1 + package.json | 2 +- scripts/allocate-tags.mjs | 128 +++++++++++++ scripts/check-tag-allocations.mjs | 37 ++++ scripts/lib/tag-allocations.mjs | 265 +++++++++++++++++++++++++++ sidebars.ts | 1 + 9 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 data/tag-allocations/blocks.csv create mode 100644 data/tag-allocations/namespaces.csv create mode 100644 docs/integrations/tag-allocations.md create mode 100644 scripts/allocate-tags.mjs create mode 100644 scripts/check-tag-allocations.mjs create mode 100644 scripts/lib/tag-allocations.mjs diff --git a/data/tag-allocations/blocks.csv b/data/tag-allocations/blocks.csv new file mode 100644 index 0000000..ea52a2b --- /dev/null +++ b/data/tag-allocations/blocks.csv @@ -0,0 +1,5 @@ +block_id,first,last,ordered_qty,buffer_pct,tag_type,last_used,status +PO7299-L1,10000001,10002625,2500,5,Avery Miniweb,,issued +PO7299-L2,10002626,10005250,2500,5,Dogbone,,issued +PO7299-L3,10005251,10007875,2500,5,Avery Web,,issued +PO7299-L4,10007876,10012600,4500,5,Tageos EOS 261 U9 white PET,,issued diff --git a/data/tag-allocations/namespaces.csv b/data/tag-allocations/namespaces.csv new file mode 100644 index 0000000..be9d3f5 --- /dev/null +++ b/data/tag-allocations/namespaces.csv @@ -0,0 +1,4 @@ +code,status,note +000,standard,TrakRF standard pool +001,reserved,customer namespace (owner in private mapping) +0A5,reserved,documentation example on the tag numbering page; never assign diff --git a/docs/integrations/tag-allocations.md b/docs/integrations/tag-allocations.md new file mode 100644 index 0000000..d9f6a9f --- /dev/null +++ b/docs/integrations/tag-allocations.md @@ -0,0 +1,35 @@ +--- +# Generated by scripts/allocate-tags.mjs from data/tag-allocations/. Do not edit: +# pnpm lint fails if this page and the data disagree. +sidebar_position: 5 +title: Tag allocation register +description: Every block of TrakRF standard-pool tag numbers issued so far, and every namespace code assigned. +--- + +# Tag allocation register + +Every block of [standard-pool](./tag-numbering#how-the-numbers-are-issued) tag numbers TrakRF has issued, and every namespace code assigned. Numbers are drawn from one global counter, in order, and never reissued. + +## Standard-pool blocks + +The next block starts at **10012601**. + +| Block | First | Last | Ordered | Buffer | Tag type | Status | Last used | +| --------- | -------- | -------- | ------- | ------ | --------------------------- | ------ | --------- | +| PO7299-L1 | 10000001 | 10002625 | 2500 | 125 | Avery Miniweb | issued | — | +| PO7299-L2 | 10002626 | 10005250 | 2500 | 125 | Dogbone | issued | — | +| PO7299-L3 | 10005251 | 10007875 | 2500 | 125 | Avery Web | issued | — | +| PO7299-L4 | 10007876 | 10012600 | 4500 | 225 | Tageos EOS 261 U9 white PET | issued | — | + +- **Ordered** is the first part of the block; **Buffer** is the overrun allowance at its end. +- **Status**: `issued` (with the bureau), `closed` (last number used reported, the rest retired), or `void` (cancelled, never reissued). + +## Namespaces + +| Code | Status | Note | +| ----- | -------- | ------------------------------------------------------------- | +| `000` | standard | TrakRF standard pool | +| `001` | reserved | customer namespace (owner in private mapping) | +| `0A5` | reserved | documentation example on the tag numbering page; never assign | + +Customer namespaces control their own sequencing, so they have no blocks here. A code is never reassigned, even after a customer leaves. diff --git a/docs/integrations/tag-numbering.md b/docs/integrations/tag-numbering.md index 6943d55..d3670ab 100644 --- a/docs/integrations/tag-numbering.md +++ b/docs/integrations/tag-numbering.md @@ -56,6 +56,7 @@ with its own code. default, adjusted to the bureau's stated tolerance). Blocks needn't start or end on round numbers. Buffer numbers that go unused are retired, not reissued. - Within a roll, tags run from the lowest number to the highest. +- The [tag allocation register](./tag-allocations) lists every block issued so far. ### What a standard decoder shows diff --git a/package.json b/package.json index 7fef1ce..9571fa4 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "docusaurus build", "serve": "docusaurus serve --host 0.0.0.0", "typecheck": "tsc", - "lint": "prettier --check . && node scripts/check-contributing.mjs && node scripts/check-tag-numbering.mjs", + "lint": "prettier --check . && node scripts/check-contributing.mjs && node scripts/check-tag-numbering.mjs && node scripts/check-tag-allocations.mjs", "lint:fix": "prettier --write .", "clear": "docusaurus clear", "swizzle": "docusaurus swizzle", diff --git a/scripts/allocate-tags.mjs b/scripts/allocate-tags.mjs new file mode 100644 index 0000000..eaae741 --- /dev/null +++ b/scripts/allocate-tags.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +// Issues TrakRF standard-pool tag numbers. Every allocation goes through this +// script and a pull request; nobody edits blocks.csv by hand to issue numbers. +// +// allocate --block-id PO1234-L1 --qty 2500 [--buffer 5] --tag-type "Avery Web" +// export --block-id PO1234-L1 [--out file.csv] per-tag bureau file +// close --block-id PO1234-L1 --last-used 10002590 +// page re-render the register page +// +// --data DIR works on another copy of the register (tests); the public page is +// only re-rendered for the real one. + +import { writeFile } from "node:fs/promises"; +import { + EXPORT_COLUMNS, + SEQ_MAX, + bufferFor, + defaultDataDir, + exportRows, + nextFirst, + readRegister, + toCsv, + validate, + writeBlocks, + writePage, +} from "./lib/tag-allocations.mjs"; + +const [command, ...rest] = process.argv.slice(2); +const opts = {}; +for (let i = 0; i < rest.length; i += 2) { + if (!rest[i].startsWith("--")) fail(`unexpected argument ${rest[i]}`); + opts[rest[i].slice(2)] = rest[i + 1]; +} +const dataDir = opts.data ?? defaultDataDir; + +function fail(message) { + console.error(`allocate-tags: ${message}`); + process.exit(1); +} +function need(name) { + if (opts[name] === undefined || opts[name] === "") + fail(`--${name} is required`); + return opts[name]; +} + +const register = await readRegister(dataDir); +const existing = validate(register); +if (existing.length) + fail(`register is inconsistent, fix it first:\n ${existing.join("\n ")}`); +const findBlock = (id) => + register.blocks.find((b) => b.block_id === id) ?? fail(`no block ${id}`); + +async function save() { + const errors = validate(register); + if (errors.length) + fail( + `refusing to write an inconsistent register:\n ${errors.join("\n ")}`, + ); + await writeBlocks(register.blocks, dataDir); + if (dataDir === defaultDataDir) await writePage(register); +} + +switch (command) { + case "allocate": { + const id = need("block-id"); + const qty = Number(need("qty")); + const pct = Number(opts.buffer ?? 5); + const tagType = need("tag-type"); + if (!Number.isInteger(qty) || qty < 1) + fail("--qty must be a positive integer"); + if (!Number.isInteger(pct) || pct < 0) + fail("--buffer must be a whole percentage"); + if (register.blocks.some((b) => b.block_id === id)) + fail(`block ${id} already exists`); + const first = nextFirst(register.blocks); + const last = first + qty + bufferFor(qty, pct) - 1; + if (last > SEQ_MAX) fail(`block would end at ${last}, past ${SEQ_MAX}`); + register.blocks.push({ + block_id: id, + first: String(first), + last: String(last), + ordered_qty: String(qty), + buffer_pct: String(pct), + tag_type: tagType, + last_used: "", + status: "issued", + }); + await save(); + console.log( + `${id}: ${first}–${last} (${qty} ordered + ${last - first + 1 - qty} buffer)`, + ); + break; + } + case "export": { + const csv = toCsv(EXPORT_COLUMNS, exportRows(findBlock(need("block-id")))); + if (opts.out) await writeFile(opts.out, csv); + else process.stdout.write(csv); + break; + } + case "close": { + const block = findBlock(need("block-id")); + const lastUsed = need("last-used"); + if (block.status !== "issued") + fail(`${block.block_id} is ${block.status}, not issued`); + if ( + !/^[0-9]{8}$/.test(lastUsed) || + +lastUsed < +block.first || + +lastUsed > +block.last + ) + fail(`--last-used must be within ${block.first}–${block.last}`); + block.last_used = lastUsed; + block.status = "closed"; + await save(); + console.log( + `${block.block_id}: closed at ${lastUsed}; ${+block.last - +lastUsed} numbers retired`, + ); + break; + } + case "page": + if (dataDir !== defaultDataDir) fail("page renders the real register only"); + await writePage(register); + console.log("docs/integrations/tag-allocations.md re-rendered"); + break; + default: + fail( + "usage: allocate | export | close | page (see the header of this script)", + ); +} diff --git a/scripts/check-tag-allocations.mjs b/scripts/check-tag-allocations.mjs new file mode 100644 index 0000000..03a7a6e --- /dev/null +++ b/scripts/check-tag-allocations.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +// Lint gate for the tag allocation register. Uniqueness of every TrakRF tag +// number rests on data/tag-allocations/blocks.csv being contiguous and +// consistent, and the public register page being exactly what that data +// renders. Run from `pnpm lint`. `--data DIR` checks another copy (tests). + +import { readFile } from "node:fs/promises"; +import { + defaultDataDir, + pagePath, + readRegister, + renderPage, + validate, +} from "./lib/tag-allocations.mjs"; + +const dataFlag = process.argv.indexOf("--data"); +const dataDir = dataFlag === -1 ? defaultDataDir : process.argv[dataFlag + 1]; + +const register = await readRegister(dataDir); +const errors = validate(register); + +if (dataDir === defaultDataDir) { + const onDisk = await readFile(pagePath, "utf8").catch(() => ""); + if (onDisk !== (await renderPage(register))) + errors.push( + "docs/integrations/tag-allocations.md does not match the data; run `node scripts/allocate-tags.mjs page`", + ); +} + +if (errors.length) { + console.error(`Tag allocation register: ${errors.length} problem(s)`); + for (const e of errors) console.error(` - ${e}`); + process.exit(1); +} +console.log( + `Tag allocation register: ${register.blocks.length} block(s), ${register.namespaces.length} namespace(s), consistent.`, +); diff --git a/scripts/lib/tag-allocations.mjs b/scripts/lib/tag-allocations.mjs new file mode 100644 index 0000000..3c378e5 --- /dev/null +++ b/scripts/lib/tag-allocations.mjs @@ -0,0 +1,265 @@ +// The TrakRF tag allocation register: one row per standard-pool block, one +// row per namespace code. Shared by the allocator (scripts/allocate-tags.mjs) +// and the lint check (scripts/check-tag-allocations.mjs) so both apply the +// same rules. The public page docs/integrations/tag-allocations.md is +// rendered from these files and must never be edited by hand. + +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import * as prettier from "prettier"; + +export const repoRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + "../..", +); +export const defaultDataDir = join(repoRoot, "data/tag-allocations"); +export const pagePath = join(repoRoot, "docs/integrations/tag-allocations.md"); + +export const SEQ_MIN = 10000001; +export const SEQ_MAX = 99999999; +export const STANDARD_PREFIX = "355E4ACFD000" + "0000"; +const BLOCK_COLUMNS = [ + "block_id", + "first", + "last", + "ordered_qty", + "buffer_pct", + "tag_type", + "last_used", + "status", +]; +const NAMESPACE_COLUMNS = ["code", "status", "note"]; +const BLOCK_STATUSES = new Set(["issued", "closed", "void"]); +const NAMESPACE_STATUSES = new Set([ + "standard", + "reserved", + "active", + "retired", +]); +const BLOCK_ID = /^([A-Za-z0-9]+)-L(\d+)$/; + +// --- CSV ------------------------------------------------------------------ + +function parseCsv(text) { + const rows = []; + let row = []; + let field = ""; + let quoted = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quoted) { + if (c === '"' && text[i + 1] === '"') ((field += '"'), i++); + else if (c === '"') quoted = false; + else field += c; + } else if (c === '"') quoted = true; + else if (c === ",") (row.push(field), (field = "")); + else if (c === "\n" || c === "\r") { + if (c === "\r" && text[i + 1] === "\n") i++; + (row.push(field), rows.push(row), (row = []), (field = "")); + } else field += c; + } + if (field !== "" || row.length) (row.push(field), rows.push(row)); + return rows; +} + +function csvField(value) { + const s = String(value ?? ""); + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +export function toCsv(columns, records) { + const lines = [columns.join(",")]; + for (const r of records) + lines.push(columns.map((c) => csvField(r[c])).join(",")); + return lines.join("\n") + "\n"; +} + +async function readTable(path, columns) { + const rows = parseCsv(await readFile(path, "utf8")); + const [header, ...body] = rows; + if (header?.join(",") !== columns.join(",")) + throw new Error(`${path}: header must be ${columns.join(",")}`); + return body.map((cells, i) => { + if (cells.length !== columns.length) + throw new Error( + `${path}: row ${i + 2} has ${cells.length} fields, expected ${columns.length}`, + ); + return Object.fromEntries(columns.map((c, j) => [c, cells[j]])); + }); +} + +export async function readRegister(dataDir = defaultDataDir) { + return { + blocks: await readTable(join(dataDir, "blocks.csv"), BLOCK_COLUMNS), + namespaces: await readTable( + join(dataDir, "namespaces.csv"), + NAMESPACE_COLUMNS, + ), + }; +} + +export async function writeBlocks(blocks, dataDir = defaultDataDir) { + await writeFile(join(dataDir, "blocks.csv"), toCsv(BLOCK_COLUMNS, blocks)); +} + +// --- Rules ---------------------------------------------------------------- + +export const bufferFor = (qty, pct) => Math.ceil((qty * pct) / 100); + +const isSeq = (s) => /^[0-9]{8}$/.test(s) && +s >= SEQ_MIN && +s <= SEQ_MAX; + +export function validate({ blocks, namespaces }) { + const errors = []; + const ids = new Set(); + let expectedFirst = SEQ_MIN; + for (const [i, b] of blocks.entries()) { + const at = `blocks.csv row ${i + 2} (${b.block_id || "no id"})`; + if (!BLOCK_ID.test(b.block_id)) + errors.push(`${at}: block_id must look like -L`); + if (ids.has(b.block_id)) errors.push(`${at}: duplicate block_id`); + ids.add(b.block_id); + for (const f of ["first", "last"]) + if (!isSeq(b[f])) + errors.push(`${at}: ${f} must be 8 digits in ${SEQ_MIN}–${SEQ_MAX}`); + if (!/^[1-9][0-9]*$/.test(b.ordered_qty)) + errors.push(`${at}: ordered_qty must be a positive integer`); + if (!/^[0-9]+$/.test(b.buffer_pct)) + errors.push(`${at}: buffer_pct must be a whole number`); + if (!BLOCK_STATUSES.has(b.status)) + errors.push(`${at}: status must be issued, closed or void`); + if (b.last_used !== "" && !isSeq(b.last_used)) + errors.push(`${at}: last_used must be empty or 8 digits`); + if (+b.first !== expectedFirst) + errors.push( + `${at}: first is ${b.first}, expected ${expectedFirst} (blocks must be contiguous, no gaps or overlaps)`, + ); + const size = +b.ordered_qty + bufferFor(+b.ordered_qty, +b.buffer_pct); + if (+b.last - +b.first + 1 !== size) + errors.push( + `${at}: range holds ${+b.last - +b.first + 1} numbers, ordered_qty + buffer is ${size}`, + ); + if ( + b.last_used !== "" && + (+b.last_used < +b.first || +b.last_used > +b.last) + ) + errors.push( + `${at}: last_used ${b.last_used} is outside ${b.first}–${b.last}`, + ); + if (b.status === "closed" && b.last_used === "") + errors.push(`${at}: closed requires last_used`); + expectedFirst = +b.last + 1; + } + const codes = new Set(); + for (const [i, n] of namespaces.entries()) { + const at = `namespaces.csv row ${i + 2} (${n.code || "no code"})`; + if (!/^[0-9A-F]{3}$/.test(n.code)) + errors.push(`${at}: code must be 3 uppercase hex characters`); + if (codes.has(n.code)) errors.push(`${at}: duplicate code`); + codes.add(n.code); + if (!NAMESPACE_STATUSES.has(n.status)) + errors.push( + `${at}: status must be standard, reserved, active or retired`, + ); + if (n.code === "000" && n.status !== "standard") + errors.push(`${at}: 000 must be standard`); + if (n.code === "0A5" && n.status === "active") + errors.push( + `${at}: 0A5 is the documentation example and is never active`, + ); + } + if (!codes.has("000")) + errors.push("namespaces.csv: 000 (standard pool) is missing"); + return errors; +} + +// --- Allocation and export ----------------------------------------------- + +export function nextFirst(blocks) { + return blocks.length ? Math.max(...blocks.map((b) => +b.last)) + 1 : SEQ_MIN; +} + +export const epcFor = (sequence) => STANDARD_PREFIX + String(sequence); + +export function exportRows(block) { + const [, order, line] = block.block_id.match(BLOCK_ID); + const orderedLast = +block.first + +block.ordered_qty - 1; + const rows = []; + for (let seq = +block.first; seq <= +block.last; seq++) { + rows.push({ + order, + line, + roll: 1, + tag_type: block.tag_type, + sequence: seq, + epc_hex: epcFor(seq), + barcode: seq, + human_readable: seq, + overrun: seq > orderedLast ? "yes" : "no", + }); + } + return rows; +} + +export const EXPORT_COLUMNS = [ + "order", + "line", + "roll", + "tag_type", + "sequence", + "epc_hex", + "barcode", + "human_readable", + "overrun", +]; + +// --- Page ----------------------------------------------------------------- + +const cell = (s) => String(s).replace(/\|/g, "\\|"); + +export async function renderPage({ blocks, namespaces }) { + const blockRows = blocks.map((b) => { + const buffer = +b.last - +b.first + 1 - +b.ordered_qty; + return `| ${cell(b.block_id)} | ${b.first} | ${b.last} | ${b.ordered_qty} | ${buffer} | ${cell(b.tag_type)} | ${b.status} | ${b.last_used || "—"} |`; + }); + const nsRows = namespaces.map( + (n) => `| \`${n.code}\` | ${n.status} | ${cell(n.note)} |`, + ); + const md = `--- +# Generated by scripts/allocate-tags.mjs from data/tag-allocations/. Do not edit: +# pnpm lint fails if this page and the data disagree. +sidebar_position: 5 +title: Tag allocation register +description: Every block of TrakRF standard-pool tag numbers issued so far, and every namespace code assigned. +--- + +# Tag allocation register + +Every block of [standard-pool](./tag-numbering#how-the-numbers-are-issued) tag numbers TrakRF has issued, and every namespace code assigned. Numbers are drawn from one global counter, in order, and never reissued. + +## Standard-pool blocks + +The next block starts at **${nextFirst(blocks)}**. + +| Block | First | Last | Ordered | Buffer | Tag type | Status | Last used | +| --- | --- | --- | --- | --- | --- | --- | --- | +${blockRows.join("\n")} + +- **Ordered** is the first part of the block; **Buffer** is the overrun allowance at its end. +- **Status**: \`issued\` (with the bureau), \`closed\` (last number used reported, the rest retired), or \`void\` (cancelled, never reissued). + +## Namespaces + +| Code | Status | Note | +| --- | --- | --- | +${nsRows.join("\n")} + +Customer namespaces control their own sequencing, so they have no blocks here. A code is never reassigned, even after a customer leaves. +`; + const config = (await prettier.resolveConfig(pagePath)) ?? {}; + return prettier.format(md, { ...config, filepath: pagePath }); +} + +export async function writePage(register) { + await writeFile(pagePath, await renderPage(register)); +} diff --git a/sidebars.ts b/sidebars.ts index 83d2109..4e7735d 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -76,6 +76,7 @@ const sidebars: SidebarsConfig = { "integrations/mqtt-message-format", "integrations/fixed-reader-setup", "integrations/tag-numbering", + "integrations/tag-allocations", ], }, ],