Skip to content

Carry the terms in the identifier, so they travel with it - #27

Merged
jwrosewell merged 8 commits into
mainfrom
feature/terms-in-the-identifier
Sep 9, 2026
Merged

jwrosewell merged 8 commits into
mainfrom
feature/terms-in-the-identifier

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Defines the Terms, one byte in the 51Did payload saying which terms document the identifier was created under, so the terms travel with the identifier instead of alongside it.

Why the identifier has to carry it

A 51Did created for marketing may only be used by a receiver that has accepted the terms it was created under. If the identifier does not say which terms those are, the answer has to travel beside it. In OpenRTB there is somewhere to put it, being the Terms Document Locator on the eids entry, and everywhere else there is not. An identifier passed as a query string parameter arrives on its own, and any hop can drop a sidecar without the identifier looking any different.

The field

One byte after the Match Key, before the creator context. It is an index into a table in this specification, not a version number.

Index Document Address
0 Not stated in the identifier none
1 Model Terms for Marketing, version 2 https://m4ow.uk/mtm/2.txt

An index rather than a version number, so a later document can live at any address rather than only at one the specification could compose from a number. An index rather than the address itself, because an address is long and because a receiver has to know the document that was in force when the identifier was made, which an address whose contents can be edited cannot prove years later. An index is never reused or repointed once published.

What the issuer writes is stated as a table too. Non-marketing writes 0, standard and personalized marketing both write 1.

The parts that are easy to get wrong

An index a package does not know is not zero. A package released before an index existed reports the index, answers with no address, and must not read it as zero. Zero says no terms are stated, whilst an unknown index says terms are stated that this package cannot name, and a receiver confusing the two would read an identifier created under terms as one created under none.

Zero does not mean unrestricted. It means the identifier does not carry the answer, so the answer comes from elsewhere, being the Terms Document Locator or whatever the surrounding protocol provides. Carrying the Terms does not remove the need for a locator where a protocol has one, because the byte answers for the identifier and the locator answers for the transaction.

The Usage and the Terms answer different questions. Where an identifier may go, and under which document it was created. A non-marketing identifier carries zero and is still barred from a demand source by its Usage.

The surface

Three members in every language, being the named value, the raw index and the address. The raw index is the one place this specification asks for a raw value, and it is asked for because a package meeting an index newer than itself otherwise has a value meaning unknown and no way to say which index it could not read.

The names are fixed here too, being Not Stated, Model Terms For Marketing 2 and Unknown, cased the way each language cases its own enumeration members, so two packages describe one thing the same way.

Implementations

Six draft pull requests read the byte against this page and none may merge before it.

Each covers the absent byte, index 1, an unknown index, both Match Key lengths, and the byte read before a creator context section. Writing them turned up three things this page had left to the implementer, all now decided here rather than six times over, being the value names, that the Reserved type leaves no byte for the Terms and so reads as index 0, and the exact address form.

All six were then run over thirteen identifiers built by hand outside every package, covering the absent byte, an explicit zero, index 1 on both Match Key lengths and on the Hashed Email type, indexes 200 and 255, the Terms before a creator context section on both lengths, a non-marketing zero, and versions 1, 2 and 3. Every package gave the same terms and match key on every case, agreed with this page, and refused the three versions.

The remote server writing the byte is the remaining piece and follows this page rather than preceding it.

A 51Did created for marketing is only usable by a receiver that has
accepted the terms it was created under, and until now the identifier
did not say which terms those were. The answer had to travel beside it,
which works in OpenRTB where there is somewhere to put it and fails
wherever an identifier is passed on its own, a query string parameter
being the ordinary case. An identifier that arrives without its terms
cannot be acted on safely.

A single byte after the Match Key carries an index into a table in this
specification. Zero says the terms are not stated in the identifier and
the receiver must consult the data accompanying it. One is the Model
Terms for Marketing version 2, at https://m4ow.uk/mtm/2.txt. A package
answers with the address for an index it knows and never fetches it.

An index rather than a version number is carried so that a later
document can live at any address, rather than only at an address this
specification could compose from a number. An index rather than the
address itself is carried because an address is long, and because a
receiver has to know the exact document in force when the identifier was
made, which an address whose contents can be edited cannot prove years
later. An index is therefore never reused or repointed once published.

A package will meet an index added after it was released, so it reports
the index, answers with no address, and must not read it as zero. Zero
says no terms are stated whilst an unknown index says terms are stated
that this package cannot name, and a receiver confusing the two would
read an identifier created under terms as one created under none. This
is why the surface exposes the raw index alongside the named value,
which is the one place the specification asks for a raw value.

Nothing about existing identifiers changes. One issued before this has a
payload that ends at the Match Key, and a reader treats a missing byte
as zero, which is the right answer for it, so absence and zero mean the
same thing and neither has to be told apart from the other. The byte
sits before the creator context, so the context section moves by one and
that section is not in production use.

This does not remove the need for a Terms Document Locator where a
protocol carries one, and the page says so, because the byte answers for
the identifier and the locator answers for the transaction.

Zero does not mean unrestricted. It means this identifier does not carry
the answer. A non-marketing identifier carries zero, since the Model
Terms govern marketing and it was not created under them, and it remains
barred from a demand source by its Usage. A marketing identifier with a
zero index must not be issued.
@jwrosewell
jwrosewell force-pushed the feature/terms-in-the-identifier branch from d9d7d11 to 5a41bd8 Compare September 9, 2026 13:34
@jwrosewell jwrosewell changed the title Carry the terms version in the identifier, so it travels with it Carry the terms in the identifier, so they travel with it Sep 9, 2026
Six packages are implementing this at once and the table gave indexes and
addresses but no names, so each was about to invent its own and a customer
reading two of them would think they were different features. The names
are now stated once, with the casing left to each language.

The Reserved type has no defined Match Key length, so a reader takes every
byte after the header as the Match Key and no byte is left for the Terms.
Such an identifier reads as index 0, which is correct under the rule above
rather than a gap in a package, and the page now says so.
Implementing this in Python showed the consequence plainly. A reader takes
the first byte after the Match Key as the Terms and nothing in the payload
says which field that byte belongs to, so a pre-change identifier carrying
a creator context would have the first byte of that section read as an
index and would report whatever it happened to be.

That is accepted rather than defended against, because the creator context
was never in production use, and it is the reason the byte can go here at
all. The page now says so, and says that anything issuing an identifier
with a context section writes the Terms byte before it.
The page said a non-marketing identifier carries zero and that a marketing
one must not, which leaves the reader to work out the positive rule for
themselves. It now states it as a table, being zero for non-marketing and
one for both standard and personalized marketing, one being the Model
Terms for Marketing version 2.

It also says what happens when a later document is published, which is
that it gains an index and the issuer writes that instead whilst
identifiers already issued keep saying what they were created under.
Nothing here has reached a customer, so a reader implementing this has
never seen an identifier without a Terms byte and does not need the
account of how the byte came to sit where it does. The rules stay and the
narrative goes, being what a payload with no byte after the Match Key
means, and that an issuer writing a creator context writes the Terms
before it.

The same sentence in the Type section, explaining that identifiers
predating the type bits read as Probabilistic, goes for the same reason.
The rule it wrapped, that a reader must not refuse the reserved type,
stays.
The envelope has a version and the payload has none, so the terms byte
could only be added by leaning on the payload length and on the creator
context not being in production. That trick does not work twice, because
a second field added after the terms cannot be told apart from a context
section, and by then identifiers carrying the terms and no version would
be in circulation.

Bits 4 and 5 become the payload version, which was the only field in the
byte with no name. Zero is the layout on this page, so nothing on the wire
changes and no identifier already issued is affected. Three values are left
unassigned, and whoever assigns the last of them has to say how the flags
are extended beyond it.

A reader must refuse a version it does not know rather than read the fields
as though the version were zero, because a later version exists precisely
because a field moved, so reading it under the old layout gives wrong
values rather than absent ones. That also makes reading the field
compulsory, since a version nothing checks protects nothing, the first
identifier carrying a new layout being misread by every package that
ignored it.
The surface asked for three members, being the named value, the raw index
and the address. It is now one, Terms, answering with the address of the
document the identifier was created under, and the package turns the index
into the address so a caller never handles the byte. Nothing already on the
page changes, so a caller written against an earlier release keeps working
untouched.

A caller can no longer tell an index the package does not know from an
index of zero, since both answer with no address. That is accepted, because
the two lead a caller to the same place, which is that the identifier does
not tell them the terms and they must look elsewhere. What a package must
still never do is compose an address for an index it does not know, since
that names a document it cannot know exists and a receiver would record
having accepted terms nobody wrote.
The payload table now states the Terms offset as 37 or 21, following the
Match Key length, with a rule that a reader takes the offset from the
length it read and never from a constant, since a constant is right for
one Type and silently wrong for the other.

"where the index is one it does not know" could be read as index 1, which
is the one index that carries an address, so it now says "not in the table
it knows". The note about a caller written against an earlier release is
removed, because nothing has been released.

The version rule now says the version belongs to the refusal and must not
appear as a member of the identifier, so a refusal that carries it in a
message or in a field on the error reads as the compliance it is. The
Terms index and the Payload version join the table of things a package
does not make public.
@jwrosewell
jwrosewell merged commit cffd514 into main Sep 9, 2026
1 check passed
@jwrosewell
jwrosewell deleted the feature/terms-in-the-identifier branch September 9, 2026 20:10
Automation51D pushed a commit to 51Degrees/pipeline-java that referenced this pull request Sep 11, 2026
…y the terms a 51Did was created under in the identifier'

* FEAT: Carry the terms a 51Did was created under in the identifier

A 51Did created for marketing may only be used by a receiver that has
accepted the terms it was created under, and until now the identifier did
not say which terms those are, so the answer had to travel beside it. In
OpenRTB there is somewhere to put it, being the Terms Document Locator on
the eids entry, and everywhere else there is not. An identifier passed as a
query string parameter arrives on its own and any hop can drop a sidecar
without the identifier looking any different, so a receiver that gets one
without its terms cannot act on it safely.

One byte after the match key now answers that, and it is an index into a
table in the specification rather than a version number, so a later
document can live at any address rather than only at one a number could
compose. Terms names the values, being NOT_STATED for index 0,
MODEL_TERMS_FOR_MARKETING_2 for index 1 and UNKNOWN for an index this
package does not know. FodId.getTerms() answers the named value,
getTermsIndex() the raw byte and getTermsUrl() the address, which is null
for both NOT_STATED and UNKNOWN and is never an empty string and never
built from the index. The package never fetches the address, it returns it.

UNKNOWN is deliberately not NOT_STATED. Zero says no terms are stated
whilst an unknown index says terms are stated that this package cannot
name, and a receiver confusing the two would read an identifier created
under terms as one created under none. The raw index is exposed alongside
the named value so that a caller meeting an index added after this release
can look the document up by hand and can report which index it could not
read.

Existing identifiers are unaffected. One issued before the byte existed has
a payload that ends at the match key, and a missing byte reads as index 0,
which says the terms are not stated in the identifier, so absence and zero
mean the same thing, no reader has to tell them apart and no presence flag
is needed. The byte adds nothing to the minimum payload lengths this
package enforces, so no identifier that read before fails to read now, and
every existing test passes untouched.

Depends on 51Degrees/specifications#27, which must merge first.

pipeline.did tests: 137 run, 0 failures, 2 skipped (the live cloud tests),
five new. The fodid developer example prints the three members and its
tests run 9, 0 failures.

* Keep the change inside the 51Did package

The example module is outside pipeline.did and printing the new members
there is a separate change, so it is taken back out and the diff is the
package alone.

* DOC: Describe the Terms byte as it is, rather than as a change

No reader has ever seen a 51Did without the Terms byte, so describing the
field as a change from a previous state gives a reader history they
cannot use.

Every rule the history was wrapped around is kept and reworded to
describe the payload instead. A payload that ends at the match key has no
Terms byte, and a missing byte reads as index zero, which says the terms
are not stated in the identifier, so absence and zero mean the same thing
and no presence flag is needed. The Terms still adds nothing to the
minimum payload lengths the package enforces.

* FEAT: Refuse a payload version this package cannot read, and answer the Terms with its address

Bits 4 and 5 of the Flags byte are the payload version. This package reads
version 0 and refuses any other with
FodIdParseStatus.UNSUPPORTED_PAYLOAD_VERSION, with the throwing readers
naming the version they found. No field is read under the layout this
package knows once the version says otherwise, because a later version
exists precisely because a field moved, so reading such a payload here
would answer with values that are wrong rather than absent. The version is
not exposed, because either the package read the layout or there is no
identifier to read fields from. FodIdParseResult carries the version it
found so the message can name it, and that field is not public.

The Terms is one member rather than three. getTerms() answers with the
address of the document the identifier was created under, and the package
turns the index into the address so a caller never handles the byte. The
raw index and the separate address member are gone and the Terms
enumeration is package-private. An index of zero and an index this package
cannot name both answer with no address, which a caller cannot tell apart,
and that is deliberate because both say the identifier does not give the
terms and the answer has to come from somewhere else. No address is ever
built from an index, since that would name a document nobody wrote.

The test factory is the creating side, so it writes both new fields. The
canonical Flags byte carries version 0, the canonical payload carries the
Terms of a personalized marketing identifier and the canonical Random
payload carries the zero a non-marketing identifier carries. A payload that
ends at the match key is now a fixture of its own, since a reader takes it
as a Terms of zero and no issuer would write one.

pipeline.did tests: 142 run, 0 failures, 0 errors, 2 skipped, being the
live cloud tests. Javadoc builds with no warnings.

* TEST: A Reserved identifier states no terms

The Reserved type takes every byte after the header as the match key, so
no byte is left to read as the Terms and getTerms() answers null. The
other five packages each test this and Java did not.

* REFACTOR: One terms table, so a new document is one member

Adding a terms document meant editing the enumeration and a second switch
that repeated the index, so the two could disagree.

Each member now carries its index alongside its address, and the index to
member map is built from the members in a static block rather than written
out again, so fromIndex is one lookup and a new document is one new member
and nothing else.

A test walks every member and fails if one does not read back from its own
index, or names a document with no address, which is what would happen if
a member and its address were added apart.

mvn test -pl pipeline.did: 34 in FodIdParseTests, 40 in FodIdTests, 0
failures.
Automation51D pushed a commit to 51Degrees/pipeline-python that referenced this pull request Sep 11, 2026
… the terms in the identifier, so they travel with it'

* FEAT: Read the Terms a 51Did was created under

The 51Did payload gains one byte after the match key, the Terms, which
says which terms document the identifier was created under so that the
terms travel with the identifier instead of alongside it. The byte is an
index into a table in the specification and not a version number, so a
later document can live at any address, and an index is never reused or
repointed once published because an identifier issued under it has to
stay readable years later.

Three members are added to FodId, being terms as a named value, the raw
terms_index, and terms_url which is the address or None. A new Terms
enumeration in terms.py carries the vocabulary and the addresses, the
same shape as the existing Usage and IdType.

Terms.NOT_STATED and Terms.UNKNOWN are different answers. NOT_STATED is
index 0 and says the identifier does not carry the answer, so it has to
come from the surrounding protocol, whilst UNKNOWN says the identifier
does state terms this package cannot name because the index was added
after the package was released. Reading UNKNOWN as NOT_STATED would read
an identifier created under terms as one created under none, so the two
are separate members and terms_index is exposed to let a caller say
which index it could not read. Neither has an address, and the package
answers with an address and never fetches it.

Existing identifiers are unaffected. A payload ending at the match key
has no byte to read and answers index 0, which is the same answer as a
zero byte, so absence and zero mean the same thing and no presence flag
exists. No field before the Terms moves and no existing test changed.

A Reserved type has no defined match key length and keeps its documented
best-effort reading, taking every byte after the header as its match
key, so it leaves no byte to read as the Terms and answers index 0.

Depends on 51Degrees/specifications#27 and must not merge before it.

* REFACTOR: Name the Terms members the same in every language

The specification gives the table of terms documents but no names for the
enumeration members, and six language packages were about to invent six
different sets. The names are now fixed across all of them, being
NotStated for index 0, ModelTermsForMarketing2 for index 1 and Unknown
for an index the package does not know, cased the way each language cases
its own enumeration members.

Python therefore uses NOT_STATED, MODEL_TERMS_FOR_MARKETING_2 and
UNKNOWN. Only the middle one changes here, from
MODEL_TERMS_FOR_MARKETING_VERSION_2, along with the string behind it.

The comment on reading the Terms of a Reserved identifier now says
plainly that answering with terms not stated is the right answer and not
a defect, because no match key length is defined for that type, so every
byte after the header is its match key and none is left to read. The next
reader should not spend time looking for a bug there.

* DOC: Describe the Terms byte as it is, rather than as a change

No reader has ever seen a 51Did without the Terms byte, so describing the
field as a change from a previous state gives a reader history they
cannot use.

Every rule the history was wrapped around is kept and reworded to
describe the payload instead. A payload that ends at the match key reads
as index 0, which says the terms are not stated in the identifier, so
absence and zero mean the same thing and no presence flag is needed. A
Reserved identifier still reads as index 0, because no match key length
is defined for that type so every byte after the header is its match key.

* FEAT: Refuse a payload version this package cannot read, and answer the Terms with its address

Bits 4 and 5 of the flags byte are the payload version. This package reads
version 0 and refuses any other with
FodIdParseStatus.UNSUPPORTED_PAYLOAD_VERSION, and the raising readers name
the version they found. No field is read under the layout this package
knows once the version says otherwise, because a later version exists
precisely because a field moved, so reading such a payload here would
answer with values that are wrong rather than absent. The version is not
exposed, because either the package read the layout or there is no
identifier to read fields from.

The Terms is one member rather than three. fod_id.terms answers with the
address of the document the identifier was created under, and the package
turns the index into the address so a caller never handles the byte. The
raw index and the separate address member are gone, and terms.py is now
_terms.py, private to the package as the layout module is, and is no
longer exported. An index of zero and an index this package cannot name
both answer with None, which a caller cannot tell apart, and that is
deliberate because both say the identifier does not give the terms and the
answer has to come from somewhere else. No address is ever built from an
index, since that would name a document nobody wrote.

The test payload builders are the creating side, so they write both new
fields. The canonical flags byte carries version 0, the canonical payload
carries the Terms of a personalized marketing identifier and the canonical
Random payload carries the zero a non-marketing identifier carries. A
payload that ends at the match key is now a fixture of its own, since a
reader takes it as a Terms of zero and no issuer would write one.

Tests: 171 passed, 2 skipped, 29 subtests passed. The offline example
prints the address.

* REFACTOR: One terms table, so a new document is one member

Adding a terms document meant editing the member, an index to member dict
and an address dict, three places that could disagree. The address lookup
also subscripted the dict directly, so a member missing from it would raise
KeyError at the caller rather than answer with no address.

Each member now carries its index and its address, and the index to member
map is built from the members rather than written out again, so a new
document is one new member and nothing else. The address comes off the
member, so there is nothing left to raise.

A test that claimed a member without an address entry would raise there
described the old design and could not fail under the new one. It is
replaced by two that check what now holds, being that every member reads
back from its own index and that a member naming a document has an https
address, and that no two members share an index.

pytest: 89 passed, 29 subtests, 0 failures.

* DOC: Correct what the package says about the version refusal

The readme told a caller to catch OwidError for a payload version this
package cannot read. _unpack_or_raise raises ValueError for every status
_read_payload returns, so the one new failure this branch introduces was
the one a caller following the readme would not catch. The status joins
the ValueError list, and the table of statuses, which described the
version refusal in prose without ever listing it.

Five counts still said two where the version check made them three,
being the members added to the OWID status vocabulary, the payload rules
_read_payload applies and the ones _from_read applies in turn. The test
pinning the vocabulary was renamed to _plus_three; the prose around it
was not.

The Terms docstring told a caller to treat UNKNOWN as terms it cannot
read and to refuse the identifier. No caller can meet UNKNOWN. It is a
private member and terms answers None for it exactly as it does for
NOT_STATED, which the package surface specification requires, so the
paragraph described a surface removed earlier on this branch. It now
says why the two reach a caller as one answer and why UNKNOWN is still a
member of its own, being that nothing may compose an address from an
index the table does not carry. The claim that members carry a name and
not the index went with it, every member having carried its index since
the terms table was made one place.

_payload_length_valid kept the old wording about the bytes beyond the
match key, which was reworded in fod_id.py, the readme and the envelope
builder when the Terms byte took that position.

The readme now also says that no identifier already issued is refused by
the version check, the two bits having been written as zero by every
issuer before the field was defined, which is the question a reader of
that section asks.

Also tidied whilst here: two PEP 8 blank-line slips in test_fodid.py, a
docstring line in fod_id.py left at 92 characters by a reflow, and the
terms constants in test_fodid.py moved to the top block with with_terms
above its first caller.

* REORG: One place records the index that states no terms

ABSENT_TERMS_INDEX in _layout.py and Terms.NOT_STATED in _terms.py both
recorded that index 0 says the terms are not stated in the identifier,
so the fact the terms table was made the single place for was still
written down twice and could disagree. _layout.py holds offsets and
lengths, and an index value is neither.

_read_terms_index now reads Terms.NOT_STATED.index, and the tests and
the envelope builder that wrote the constant name the same member.
TERMS_LENGTH stays in _layout.py, being a length.

The assertion that the layout gives the constant the value zero is
dropped, because test_every_member_agrees_with_the_table already pins
NOT_STATED to index 0 and there is no second place left to disagree
with it.

---------

Co-authored-by: Oleksandr Lazarenko <oleksandr.lazarenko@postindustria.com>
Automation51D pushed a commit to 51Degrees/pipeline-node that referenced this pull request Sep 11, 2026
…y the terms in the identifier, so a receiver can read what a 51Did was created under'

* FEAT: Read the terms a 51Did was created under from the identifier

A 51Did created for marketing may only be used by a receiver that has
accepted the terms it was created under, and until now the identifier did
not say which terms those are, so the answer had to travel beside it. In
OpenRTB there is somewhere to put it, being the Terms Document Locator on
the eids entry, and everywhere else there is not. An identifier passed as
a query string parameter arrives on its own, and any hop can drop what was
sent alongside it without the identifier looking any different.

The specification adds one byte after the match key and before the creator
context, and this package reads it. The byte is an index into a table in
the specification and is not a version number, so that a later document
can live at any address rather than only at an address a number could be
turned into. Index 0 says the terms are not stated in the identifier and
index 1 is the Model Terms for Marketing version 2, at
https://m4ow.uk/mtm/2.txt. An index is never reused or repointed once
published, because repointing one would rewrite what a past identifier
says it agreed to.

Three members, named as the package surface page states for Node.

1. fodId.terms, a named value like the existing usage and type, being one
of Terms.NOT_STATED, Terms.MODEL_TERMS_FOR_MARKETING_2 and Terms.UNKNOWN.

2. fodId.termsIndex, the byte itself. It is the one place the
specification asks for a raw value, and it is asked for because a caller
meeting an index added after this package was released otherwise has a
value meaning unknown and no way to say which index it could not read.

3. fodId.termsUrl, the address, which is null for NOT_STATED and for
UNKNOWN, never an empty string and never an address built from the index.
Nothing here fetches the address, because what to do with the document is
the caller's decision.

An index this package does not know is not zero. NOT_STATED says no terms
are stated, whilst UNKNOWN says terms are stated that this package cannot
name, and a caller confusing the two would read an identifier created
under terms as one created under none, so the two values are distinct and
a test holds them apart.

Existing identifiers are unaffected. One issued before the byte existed
has a payload that ends at the match key, and a payload with no byte to
read answers with an index of zero, which is the right answer for it, so
absence and zero mean the same thing and no presence flag or format
version is needed. The least payload lengths are unchanged, so nothing
that parsed before stops parsing. An identifier of the Reserved type
cannot carry a byte this reader can find, because the match key length for
that type is not defined and every byte after the header is the match key,
so it reads as NOT_STATED too, which is correct and is noted in a comment
so the next reader does not take it for a missing case.

The byte follows the match key, whose length depends on the identifier
type, so the offset is worked out from the type rather than being a
constant. internal/layout.js gains TERMS_LENGTH and no offset.

The readme gains a section on the terms, the payload layout paragraph now
names the byte, and the offline example builds a sample carrying index 1
and prints all three members. The generated type declarations were rebuilt
for the three files this change touches.

Depends on 51Degrees/specifications#27 and must not merge before it.

fiftyone.pipeline.did tests: 157 passed, 13 new, 144 before. Whole
repository unit run: 287 passed in 23 suites. Lint: 0 errors.

* DOC: Describe the Terms byte as it is, rather than as a change

No reader has ever seen a 51Did without the Terms byte, so describing the
field as a change from a previous state gives a reader history they
cannot use.

Every rule the history was wrapped around is kept and reworded to
describe the payload instead. A payload that ends at the match key
carries no terms byte and reads as index zero, which says the terms are
not stated, so absence and zero are the same answer. The byte is read at
the offset the match key ends at, so where it sits follows the match key
length the type selects, and a Reserved identifier leaves no byte for it.

* FEAT: Refuse a payload version this package cannot read, and answer the terms with their address

Bits 4 and 5 of the flags byte are the payload version. This package reads
version 0 and refuses any other with
ParseStatus.UNSUPPORTED_PAYLOAD_VERSION, and the error the throwing
surfaces raise names the version it found. No field is read under the
layout this package knows once the version says otherwise, because a later
version exists precisely because a field moved, so reading such a payload
here would answer with values that are wrong rather than absent. The
version is not exposed, because either the package read the layout or
there is no identifier to read fields from.

The terms are one member rather than three. fodId.terms answers with the
address of the document the identifier was created under, and the package
turns the index into the address so a caller never handles the byte. The
raw index and the separate address member are gone, and terms.js is no
longer exported from the package entry point. An index of zero and an
index this package cannot name both answer with no address, which a caller
cannot tell apart, and that is deliberate because both say the identifier
does not give the terms and the answer has to come from somewhere else. No
address is ever built from an index, since that would name a document
nobody wrote.

The test envelope builders are the creating side, so they write both new
fields. The canonical flags byte carries version 0, the canonical payload
carries the terms of a personalized marketing identifier and the canonical
Random payload carries the zero a non-marketing identifier carries. A
payload that ends at the match key is now a fixture of its own, since a
reader takes it as a terms index of zero and no issuer would write one.

fiftyone.pipeline.did tests: 162 passed, 2 skipped, being the live cloud
tests. Lint: 0 errors. The offline example prints the address.

* REFACTOR: One terms table, so a new document is one row

The name and the address lived in two lists side by side, indexed by
position, so adding a terms document meant editing both and a named value,
and a row added to one list and not the other would answer with undefined
rather than no address.

One TABLE now holds a row per document carrying the name and the address
together, and fromIndex, name and url all read it. A new document is one
row and one named value.

A test walks all 256 indexes and fails if a row does not answer its own
index, if a row that names a document has no https address, or if index 0
answers with anything but no address.

npx jest: 82 passed. eslint: 0 errors, with the same 113 pre-existing
warnings.

* DOC: Say shipped code, since the tests carry the address on purpose

The table comment claimed to be the only place in the package carrying the
address. A reader who greps finds it in the tests too, where it is written
out deliberately so that a test never compares the reader with itself, so
the claim now says shipped code and explains the test.

* DOC: Say that a refused payload version throws RangeError

The throwing surface table named RangeError for two payload statuses and
sent every other status to FodIdParseError, so a caller reading it would
catch the wrong class for the one new failure this branch introduces.
errorFor throws RangeError for UNSUPPORTED_PAYLOAD_VERSION as well, and
the table now says so. The status table gained the row it never had,
having been described in prose further up and nowhere a caller
enumerates the vocabulary from.

The test that pins the throwing contract checked two of the three and
said "the two 51Did payload statuses" over the top of them. It now
builds a payload of version 1 and asserts RangeError carrying the
status, so the class a caller is told to catch is the class the suite
holds the reader to. Nothing pinned that before, which is how the table
came to disagree with the reader.

Four places still said two 51Did payload statuses where the version
check made them three, being the errorFor comment, the readme's account
of the vocabulary, the test comment above the case that now covers all
three, and the class comment on FodIdParseError. That last file is not
otherwise touched by this branch, so nothing in the change set led to
it; it was found by searching for the claim rather than by walking the
diff.

unpack returns payloadVersion on the refusal path and errorFor reads it
to name the version, but only termsIndex reached the documented return
type when the two were added together.

* REORG: Put the terms table on the internal path, and bound its lookups

terms.js sat at the package root beside fodId.js, idType.js and
usage.js, which are the exported modules, whilst the module that is not
public in this package is internal/layout.js. Nothing leaked, because
index.js does not export it and the exports map in package.json offers
only the entry point, but it read as public by where it was. It now sits
beside layout, and its generated type sits beside layout's.

name and url subscripted the table with whatever they were given, so a
value that was neither UNKNOWN nor a row raised a TypeError at the
caller rather than answering with no address. The one call in the reader
passes the result of fromIndex and was always safe, so nothing was
broken, but the shape that raises had no reason to stay: the table is
the thing a new terms document changes, and a lookup into it should
answer.

rowFor decides the bounds once. fromIndex, name and url all go through
it, where fromIndex carried a second copy of the same bounds test. A
test covers a value outside the table, and it fails against the old
lookup with the TypeError it used to raise.

The index a payload with no terms byte reads as is Terms.NOT_STATED
rather than a bare zero, since that is the value it means and the
comment above it was already having to say so.

---------

Co-authored-by: Oleksandr Lazarenko <oleksandr.lazarenko@postindustria.com>
jwrosewell added a commit to 51Degrees/pipeline-php-did that referenced this pull request Sep 13, 2026
* FEAT: Carry the terms a 51Did was created under, so they travel with it

The payload now holds one byte after the match key, an index into the
terms table in the specification rather than a version number. Index 0
says the terms are not stated in the identifier, and index 1 is the Model
Terms for Marketing version 2 at https://m4ow.uk/mtm/2.txt.

FodId gains getTerms(), getTermsIndex() and getTermsUrl(), named as the
package surface page requires, and Terms is a named value alongside the
existing IdType. An index this release does not know reads as
Terms::Unknown and never as Terms::NotStated, because zero says no terms
are stated whilst an unknown index says terms are stated that this
package cannot name, and reading the two as one would take an identifier
created under terms for one created under none. The index is reported so
that a caller can say which one it could not read, and the address is
returned and never fetched.

Existing identifiers are unaffected. A payload ending at the match key
reads as index 0, so absence and a byte holding zero mean the same thing
and no presence flag is needed. No length rule changed.

Depends on 51Degrees/specifications#27.

* DOC: Describe the Terms byte as it is, rather than as a change

No reader has ever seen a 51Did without the Terms byte, so describing the
field as a change from a previous state gives a reader history they
cannot use.

Every rule the history was wrapped around is kept and reworded to
describe the payload instead. A payload that ends at the match key
carries no Terms byte and reads as index 0, so absence and a byte holding
zero mean the same thing and no presence flag exists. The byte sits at
the offset the match key ends at, so where it sits follows the match key
length the type selects, and a Reserved identifier leaves none for it.

* FEAT: Refuse a payload version this package cannot read, and answer the terms with their address

Bits 4 and 5 of the flags byte are the payload version. This package reads
version 0 and refuses any other with
FodIdParseStatus::UnsupportedPayloadVersion, and the raising readers name
the version they found. No field is read under the layout this package
knows once the version says otherwise, because a later version exists
precisely because a field moved, so reading such a payload here would
answer with values that are wrong rather than absent. The version is not
exposed, because either the package read the layout or there is no
identifier to read fields from.

The terms are one member rather than three. getTerms() answers with the
address of the document the identifier was created under, and the package
turns the index into the address so a caller never handles the byte. The
raw index and the separate address member are gone and the Terms enum is
marked internal, so it is not part of the published surface. An index of
zero and an index this package cannot name both answer with null, which a
caller cannot tell apart, and that is deliberate because both say the
identifier does not give the terms and the answer has to come from
somewhere else. No address is ever built from an index, since that would
name a document nobody wrote.

The test payload builders are the creating side, so they write both new
fields. The canonical flags byte carries version 0, the canonical payload
carries the terms of a personalized marketing identifier and the canonical
Random payload carries the zero a non-marketing identifier carries. A
payload that ends at the match key is now a fixture of its own, since a
reader takes it as an index of zero and no issuer would write one.

Tests: 137, assertions 1333, 2 skipped, being the live client tests. The
offline example prints the address and php -l is clean.

* DOC: Drop the reference to a Terms index accessor that does not exist

The Unknown case pointed a reader at FodId::getTermsIndex(), which is not
a member of this package, since the index is deliberately not published.
It now says what Unknown answers with, being no address, and why.

* REFACTOR: One terms table, so a new document is one row

Adding a terms document meant editing the case, a constant for its
address, an arm of fromIndex and an arm of url, four places carrying the
same fact.

The enum is now backed by the Terms index, so fromIndex is tryFrom with
Unknown as the fallback, and one ADDRESSES table keyed by the case's own
value carries the addresses, so the index number is written once. A new
document is one new case and one new row.

Unknown is backed by -1. A Terms index read from a payload is one byte, so
it is 0 to 255 and can never be negative, which is what makes -1 safe as
the value that is not in the table.

Two tests check the table: every case reads back from its own index and a
case naming a document has an https address, and all 256 indexes the table
does not carry are Unknown with no address.

phpunit: 74 tests, 1655 assertions, 0 failures.

* DOC: Say shipped code, since the tests carry the address on purpose

The table comment claimed to be the only place in the package carrying the
address. A reader who greps finds it in the tests too, where it is written
out deliberately so that a test never compares the reader with itself, so
the claim now says shipped code and explains the test.

* FIX: Key the address table with a literal, so PHP 8.1 can read it

The table was keyed by self::ModelTermsForMarketing2->value, which reads
an enum property inside a constant expression. That needs PHP 8.3, and
this package supports 8.1, so it was a fatal parse error there before a
single test ran. CI said so on both 8.1 jobs and passed on both 8.3 ones.
It went unnoticed because it was written and checked on 8.5.

The key is now the literal 1, which costs the single source the enum
value gave, so a test holds the two together and fails if the literal ever
stops matching the case.

phpunit: 75 tests, 1657 assertions, 0 failures, on 8.5 locally. The 8.1
job is what this is for.

* TEST: Read the terms, the flags and the consent bit from the live cloud

The live test created one non-marketing identifier and checked that it
verified. It never read the terms, the usage, the identifier type or the
bit that says where the usage came from, so it passed just as happily
against a service that wrote none of them.

Two tests are added. The first asks for each id.usage the service offers
and asserts the usage this package answers with, the terms address that
usage must produce, that the usage was recorded as stated by the caller,
and that the type is probabilistic. Read through the accessors rather
than by masking, because the usage values are cumulative, being 001, 011
and 111, so a caller masking the byte for the non-marketing bit reads
every marketing identifier as non-marketing.

The second covers the half a caller cannot state for itself. A consent
management platform sends an IAB TCF consent string and no usage of its
own, the service decodes the purposes, decides the usage, and records in
the identifier that it decided rather than was told. Two strings are
sent, one granting all twelve purposes and one granting the Appendix 1
standard set, and each identifier must read as the matching usage with
the consent bit set. No id.usage goes with them, because a stated usage
wins over a consent string and sending one would prove the opposite.

The address and the consent strings are written out here rather than
taken from the package or shared with the service's own tests, because a
test that asked either what it expects would agree with it whatever it
said.

These sit on this branch rather than a branch of their own, because
getTerms() only exists here. main has the usage and type accessors but
not the terms.

Both tests write NOTHING PROVEN to stderr where they read no marketing
identifier, so a key with no marketing entitlement cannot produce a pass
that checked nothing.

Verified: php -l clean, and vendor/bin/phpunit runs 142 tests where it
ran 140, with 4 skipped rather than 2, those two extra being the new
live tests with no resource key set. That is the state off CI, and the
assertions need the live service.

* TEST: Report a skip, not stderr, when the run proved nothing

A runner prints the test counts and not the captured output, so a key
that returned no marketing identifier would report a pass identical to
one that proved the byte. The two summary reports are now skips, which
show in the visible counts. The per-usage notes inside the loops stay on
stderr, because they explain a skip rather than being one.

Verified: php -l clean and phpunit runs 142 tests with 4 skipped.
jwrosewell added a commit to 51Degrees/rust that referenced this pull request Sep 13, 2026
* FEAT: Read the terms a 51Did was created under

A 51Did created for marketing may only be used by a receiver that has
accepted the terms it was created under, and until now the identifier did
not say which terms those are. The answer had to travel beside it, which
works in OpenRTB, where the Terms Document Locator has somewhere to go, and
nowhere else, because an identifier passed as a query string parameter
arrives on its own and any hop can drop a sidecar without the identifier
looking any different.

The specification adds one byte after the match key, before the creator
context, holding an index into a table it publishes. Index 0 says the terms
are not stated in the identifier and index 1 is the Model Terms for
Marketing, version 2, at https://m4ow.uk/mtm/2.txt. It is an index rather
than a version number so that a later document can live at any address, and
an index rather than the address itself because a receiver has to know the
exact document in force when the identifier was made.

This adds three members to FodId, matching the package surface page.
terms() answers a named Terms value, terms_index() answers the raw byte, and
terms_url() answers the address as an Option, being None for index 0 and for
an index this crate does not know.

An index this crate does not know is Terms::Unknown and never
Terms::NotStated. Zero says no terms are stated, whilst an unknown index
says terms are stated that this crate cannot name, and a receiver confusing
the two would read an identifier created under terms as one created under
none. The raw index stays available so a caller meeting a newer index can
say which one it could not read. The address is answered and never fetched.

Existing identifiers are unaffected. A payload issued before the terms
existed ends at the match key, and a missing byte reads as index 0, which is
exactly what such an identifier means, so absence and zero need no telling
apart and no presence flag exists. Every test that passed before passes
unchanged.

The byte follows the match key, so where it sits moves with the match key
length the identifier type requires, being 37 for a probabilistic or hashed
email identifier and 21 for a random one. A reserved type has no defined
match key length and takes every remaining byte as its value, so a reserved
identifier states no terms until that length is assigned.

Depends on 51Degrees/specifications#27, which must merge first.

* FEAT: Use the agreed Terms member name across the packages

The specification does not name the values, and the six packages were about
to invent six different names for the same three concepts, so the names are
now fixed across all of them. The concepts are NotStated for index 0,
ModelTermsForMarketing2 for index 1 and Unknown for an index the package
does not know, each cased the way its own language cases an enumeration
member, which is NotStated, ModelTermsForMarketing2 and Unknown in Rust.

Only the index 1 member changes here, from ModelTermsForMarketingVersion2.

The read also says plainly why a reserved identifier states no terms, since
a reserved type has no assigned match key length and takes every byte after
the header as its value, so there is no byte left for the terms to be taken
from. Index 0 is the right answer there and not a fault, and the next reader
should not have to work that out.

* DOC: Describe the terms byte as it is, rather than as a change

No reader has ever seen a 51Did without the terms byte, so describing the
field as a change from a previous state gives a reader history they
cannot use.

Every rule the history was wrapped around is kept and reworded to
describe the payload instead. A payload that ends at the match key
carries no terms byte and a missing byte is index 0, so absence and zero
say the same thing. The byte sits after the match key, so where it sits
follows the match key length the type selects, and a reserved identifier
has no byte left for it and reads as index 0.

* FEAT: Refuse a payload version this crate cannot read, and answer the terms with their address

Bits 4 and 5 of the flags byte are the payload version. This crate reads
version 0 and refuses any other with Error::UnsupportedPayloadVersion,
which carries the version it found so the message names it. No field is
read under the layout this crate knows once the version says otherwise,
because a later version exists precisely because a field moved, so reading
such a payload here would answer with values that are wrong rather than
absent. The version is not exposed, because either the crate read the
layout or there is no identifier to read fields from.

The terms are one member rather than three. FodId::terms answers with the
address of the document the identifier was created under, and the crate
turns the index into the address so a caller never handles the byte. The
raw index and the separate address member are gone and the Terms
enumeration is now pub(crate). An index of zero and an index this crate
cannot name both answer with None, which a caller cannot tell apart, and
that is deliberate because both say the identifier does not give the terms
and the answer has to come from somewhere else. No address is ever built
from an index, since that would name a document nobody wrote.

The test fixtures are the creating side, so they write both new fields.
The canonical flags byte carries version 0 and the canonical payload
carries the terms of a personalized marketing identifier. A payload that
ends at the match key is now a fixture of its own, since a reader takes it
as an index of zero and no issuer would write one.

fodid tests: 24 unit, 52 integration and 19 documentation tests pass, with
1 ignored, being the live cloud test. cargo fmt is clean and cargo clippy
with -D warnings finds nothing.

* REFACTOR: One terms table, so a new document is one row

Adding a terms document meant editing the variant, an arm of from_index
and an arm of url, so the index, the name and the address were written in
three places that could disagree.

TERMS_TABLE now holds one row per document carrying all three together,
and both lookups read it, so a new document is one row and one variant.
The bare 0 the absent byte fell back to is now NOT_STATED_INDEX, named
where it is explained.

Four unit tests check the table itself: every row round trips through both
lookups and carries an https address, no row claims the not stated index,
no index appears twice, and every one of the 256 indexes the table does not
carry is Unknown with no address.

cargo test -p fodid: 28 unit, 52 integration, 19 doc, 0 failures. Clippy
clean over all targets.

* DOC: Say shipped code, since the tests carry the address on purpose

The table comment claimed to be the only place in the package carrying the
address. A reader who greps finds it in the tests too, where it is written
out deliberately so that a test never compares the reader with itself, so
the claim now says shipped code and explains the test.

* STYLE: Format the terms table and its tests the way rustfmt wants

The only red job on this branch was `cargo fmt --all -- --check`, which
reported three differences in fodid/src/fodid.rs, being the TERMS_TABLE
constant and two of its tests. Every other job was green, including the
wasm32-wasip1 build and the examples check.

This is the output of `cargo fmt --all` and nothing else. No behaviour
changes.

Verified: `cargo fmt --all -- --check` is now silent, and `cargo test -p
fodid` runs 99 tests with 0 failures and 1 ignored.

* DOC: Point the terms note at the flags, which is the surface this crate has

`cargo doc` failed the build with an unresolved intra-doc link, because
the note on `terms` pointed at `FodId::usage` and this crate has no such
item. It exposes the raw `flags` byte and documents the usage bits
within it, so the link had nowhere to go and `-D warnings` turned that
into an error.

The sentence now points at `flags` and says what the usage bits do,
which is bar a non-marketing identifier from a demand source. That is
the wording the .NET package already carries for the same note.

Verified: `cargo doc -p fodid --no-deps` with `RUSTDOCFLAGS=-D warnings`
is clean, `cargo fmt --all -- --check` is silent, `cargo clippy -p fodid
--all-targets -- -D warnings` is clean, and `cargo test -p fodid` runs
99 tests with 0 failures and 1 ignored.

* TEST: Check the terms, the flags and the consent bit against the live cloud

The live cloud test asked the service for a 51Did at each id.usage and
then checked the envelope around it, being the match key length, a
minimum payload length, a non-empty domain and a base64 round trip. It
never read the terms and never read the flags, so it passed just as
happily against a service that wrote neither.

Every identifier it reads now asserts the terms address the usage must
produce, which is nothing for non-marketing and the Model Terms for
Marketing for the two marketing usages, the usage the crate answers with,
that the usage was recorded as stated by the caller, and that the type is
probabilistic. Read through the accessors rather than by masking, because
the usage values are cumulative, being 001, 011 and 111, so a caller
masking the byte for the non-marketing bit reads every marketing
identifier as non-marketing.

The new test covers the half a caller cannot state for itself. A consent
management platform sends an IAB TCF consent string and no usage of its
own, the service decodes the purposes, decides the usage, and records in
the identifier that it decided rather than was told. Two strings are
sent, one granting all twelve purposes and one granting the Appendix 1
standard set, and each identifier must read as the matching usage with
the consent bit set. No id.usage goes with them, because a stated usage
wins over a consent string and sending one would prove the opposite.

The address and the consent strings are written out here rather than
taken from the crate or shared with the service's own tests, because a
test that asked either what it expects would agree with it whatever it
said.

The local case struct was called Usage and shadowed the crate's enum,
which the assertions now need, so it is UsageCase.

Rust has no inconclusive result, so where a run reads no marketing
identifier the consent test says NOTHING PROVEN rather than leaving a
pass to be read as evidence.

Verified: cargo fmt --all --check silent, cargo clippy -p fodid
--all-targets -D warnings clean, cargo doc clean under RUSTDOCFLAGS=-D
warnings, and cargo test -p fodid at 103 passed, 0 failed and 2 ignored,
those two being the live tests. The assertions need the live service, so
the Cloud tests step on this pull request is what proves them.

* BUILD: Move fodid to 4.5.4, so merging this actually publishes it

crates.io already serves fodid 4.5.3, which is the version this branch
carried, and ci/publish-crates.sh skips a crate whose version is already
published. Merging as it stood would have published nothing and reported
no error, so the terms reader would have reached no consumer. Rust 38
merged that way this morning and is in exactly that state.

fodid-cloud moves with it, being unpublished and pinned to fodid by
version, so the two stay in step as they were at 4.5.3.

Verified: cargo build for both crates is clean, cargo fmt --all --check
is silent, and cargo test -p fodid runs 103 tests with 0 failures.

* REVERT: Drop the version bump, the release branch already carries it

I bumped fodid to 4.5.4 here this morning, not having looked for an
existing release pull request. There is one, 31, opened on 2 September,
which moves the same line for the same reason and explains the same
mechanism, being that ci/publish-crates.sh skips a version already on
crates.io so a merge without a bump publishes nothing.

This branch now targets that release branch rather than main, so the
bump arrives from the base and duplicating it here would only conflict.

The fodid-cloud change goes with it. Pull request 31 reasoned that
fodid-cloud needs none, because its dependency reads a bare version
which Cargo treats as a caret requirement and so accepts 4.5.4, and that
is the author's decision to make rather than mine to override in
passing.
jwrosewell added a commit to 51Degrees/rust that referenced this pull request Sep 13, 2026
…live cloud test (#31)

* CHORE: Release fodid 4.5.4 so the match key constants reach crates.io.

The match key rename landed in 30 but the crate version did not
move, and ci/publish-crates.sh skips any version already on
crates.io, so the publish run on that merge finished green having
published nothing. crates.io still serves 4.5.3, which carries the
old constant names only.

This bumps the fodid crate so the next publish has something to
release. fiftyone-fodid-cloud needs no change, because its
dependency on fodid is written as 4.5.3, which Cargo reads as
caret 4.5.3 and which 4.5.4 satisfies.

* Carry the terms a 51Did was created under, so they travel with it (#41)

* FEAT: Read the terms a 51Did was created under

A 51Did created for marketing may only be used by a receiver that has
accepted the terms it was created under, and until now the identifier did
not say which terms those are. The answer had to travel beside it, which
works in OpenRTB, where the Terms Document Locator has somewhere to go, and
nowhere else, because an identifier passed as a query string parameter
arrives on its own and any hop can drop a sidecar without the identifier
looking any different.

The specification adds one byte after the match key, before the creator
context, holding an index into a table it publishes. Index 0 says the terms
are not stated in the identifier and index 1 is the Model Terms for
Marketing, version 2, at https://m4ow.uk/mtm/2.txt. It is an index rather
than a version number so that a later document can live at any address, and
an index rather than the address itself because a receiver has to know the
exact document in force when the identifier was made.

This adds three members to FodId, matching the package surface page.
terms() answers a named Terms value, terms_index() answers the raw byte, and
terms_url() answers the address as an Option, being None for index 0 and for
an index this crate does not know.

An index this crate does not know is Terms::Unknown and never
Terms::NotStated. Zero says no terms are stated, whilst an unknown index
says terms are stated that this crate cannot name, and a receiver confusing
the two would read an identifier created under terms as one created under
none. The raw index stays available so a caller meeting a newer index can
say which one it could not read. The address is answered and never fetched.

Existing identifiers are unaffected. A payload issued before the terms
existed ends at the match key, and a missing byte reads as index 0, which is
exactly what such an identifier means, so absence and zero need no telling
apart and no presence flag exists. Every test that passed before passes
unchanged.

The byte follows the match key, so where it sits moves with the match key
length the identifier type requires, being 37 for a probabilistic or hashed
email identifier and 21 for a random one. A reserved type has no defined
match key length and takes every remaining byte as its value, so a reserved
identifier states no terms until that length is assigned.

Depends on 51Degrees/specifications#27, which must merge first.

* FEAT: Use the agreed Terms member name across the packages

The specification does not name the values, and the six packages were about
to invent six different names for the same three concepts, so the names are
now fixed across all of them. The concepts are NotStated for index 0,
ModelTermsForMarketing2 for index 1 and Unknown for an index the package
does not know, each cased the way its own language cases an enumeration
member, which is NotStated, ModelTermsForMarketing2 and Unknown in Rust.

Only the index 1 member changes here, from ModelTermsForMarketingVersion2.

The read also says plainly why a reserved identifier states no terms, since
a reserved type has no assigned match key length and takes every byte after
the header as its value, so there is no byte left for the terms to be taken
from. Index 0 is the right answer there and not a fault, and the next reader
should not have to work that out.

* DOC: Describe the terms byte as it is, rather than as a change

No reader has ever seen a 51Did without the terms byte, so describing the
field as a change from a previous state gives a reader history they
cannot use.

Every rule the history was wrapped around is kept and reworded to
describe the payload instead. A payload that ends at the match key
carries no terms byte and a missing byte is index 0, so absence and zero
say the same thing. The byte sits after the match key, so where it sits
follows the match key length the type selects, and a reserved identifier
has no byte left for it and reads as index 0.

* FEAT: Refuse a payload version this crate cannot read, and answer the terms with their address

Bits 4 and 5 of the flags byte are the payload version. This crate reads
version 0 and refuses any other with Error::UnsupportedPayloadVersion,
which carries the version it found so the message names it. No field is
read under the layout this crate knows once the version says otherwise,
because a later version exists precisely because a field moved, so reading
such a payload here would answer with values that are wrong rather than
absent. The version is not exposed, because either the crate read the
layout or there is no identifier to read fields from.

The terms are one member rather than three. FodId::terms answers with the
address of the document the identifier was created under, and the crate
turns the index into the address so a caller never handles the byte. The
raw index and the separate address member are gone and the Terms
enumeration is now pub(crate). An index of zero and an index this crate
cannot name both answer with None, which a caller cannot tell apart, and
that is deliberate because both say the identifier does not give the terms
and the answer has to come from somewhere else. No address is ever built
from an index, since that would name a document nobody wrote.

The test fixtures are the creating side, so they write both new fields.
The canonical flags byte carries version 0 and the canonical payload
carries the terms of a personalized marketing identifier. A payload that
ends at the match key is now a fixture of its own, since a reader takes it
as an index of zero and no issuer would write one.

fodid tests: 24 unit, 52 integration and 19 documentation tests pass, with
1 ignored, being the live cloud test. cargo fmt is clean and cargo clippy
with -D warnings finds nothing.

* REFACTOR: One terms table, so a new document is one row

Adding a terms document meant editing the variant, an arm of from_index
and an arm of url, so the index, the name and the address were written in
three places that could disagree.

TERMS_TABLE now holds one row per document carrying all three together,
and both lookups read it, so a new document is one row and one variant.
The bare 0 the absent byte fell back to is now NOT_STATED_INDEX, named
where it is explained.

Four unit tests check the table itself: every row round trips through both
lookups and carries an https address, no row claims the not stated index,
no index appears twice, and every one of the 256 indexes the table does not
carry is Unknown with no address.

cargo test -p fodid: 28 unit, 52 integration, 19 doc, 0 failures. Clippy
clean over all targets.

* DOC: Say shipped code, since the tests carry the address on purpose

The table comment claimed to be the only place in the package carrying the
address. A reader who greps finds it in the tests too, where it is written
out deliberately so that a test never compares the reader with itself, so
the claim now says shipped code and explains the test.

* STYLE: Format the terms table and its tests the way rustfmt wants

The only red job on this branch was `cargo fmt --all -- --check`, which
reported three differences in fodid/src/fodid.rs, being the TERMS_TABLE
constant and two of its tests. Every other job was green, including the
wasm32-wasip1 build and the examples check.

This is the output of `cargo fmt --all` and nothing else. No behaviour
changes.

Verified: `cargo fmt --all -- --check` is now silent, and `cargo test -p
fodid` runs 99 tests with 0 failures and 1 ignored.

* DOC: Point the terms note at the flags, which is the surface this crate has

`cargo doc` failed the build with an unresolved intra-doc link, because
the note on `terms` pointed at `FodId::usage` and this crate has no such
item. It exposes the raw `flags` byte and documents the usage bits
within it, so the link had nowhere to go and `-D warnings` turned that
into an error.

The sentence now points at `flags` and says what the usage bits do,
which is bar a non-marketing identifier from a demand source. That is
the wording the .NET package already carries for the same note.

Verified: `cargo doc -p fodid --no-deps` with `RUSTDOCFLAGS=-D warnings`
is clean, `cargo fmt --all -- --check` is silent, `cargo clippy -p fodid
--all-targets -- -D warnings` is clean, and `cargo test -p fodid` runs
99 tests with 0 failures and 1 ignored.

* TEST: Check the terms, the flags and the consent bit against the live cloud

The live cloud test asked the service for a 51Did at each id.usage and
then checked the envelope around it, being the match key length, a
minimum payload length, a non-empty domain and a base64 round trip. It
never read the terms and never read the flags, so it passed just as
happily against a service that wrote neither.

Every identifier it reads now asserts the terms address the usage must
produce, which is nothing for non-marketing and the Model Terms for
Marketing for the two marketing usages, the usage the crate answers with,
that the usage was recorded as stated by the caller, and that the type is
probabilistic. Read through the accessors rather than by masking, because
the usage values are cumulative, being 001, 011 and 111, so a caller
masking the byte for the non-marketing bit reads every marketing
identifier as non-marketing.

The new test covers the half a caller cannot state for itself. A consent
management platform sends an IAB TCF consent string and no usage of its
own, the service decodes the purposes, decides the usage, and records in
the identifier that it decided rather than was told. Two strings are
sent, one granting all twelve purposes and one granting the Appendix 1
standard set, and each identifier must read as the matching usage with
the consent bit set. No id.usage goes with them, because a stated usage
wins over a consent string and sending one would prove the opposite.

The address and the consent strings are written out here rather than
taken from the crate or shared with the service's own tests, because a
test that asked either what it expects would agree with it whatever it
said.

The local case struct was called Usage and shadowed the crate's enum,
which the assertions now need, so it is UsageCase.

Rust has no inconclusive result, so where a run reads no marketing
identifier the consent test says NOTHING PROVEN rather than leaving a
pass to be read as evidence.

Verified: cargo fmt --all --check silent, cargo clippy -p fodid
--all-targets -D warnings clean, cargo doc clean under RUSTDOCFLAGS=-D
warnings, and cargo test -p fodid at 103 passed, 0 failed and 2 ignored,
those two being the live tests. The assertions need the live service, so
the Cloud tests step on this pull request is what proves them.

* BUILD: Move fodid to 4.5.4, so merging this actually publishes it

crates.io already serves fodid 4.5.3, which is the version this branch
carried, and ci/publish-crates.sh skips a crate whose version is already
published. Merging as it stood would have published nothing and reported
no error, so the terms reader would have reached no consumer. Rust 38
merged that way this morning and is in exactly that state.

fodid-cloud moves with it, being unpublished and pinned to fodid by
version, so the two stay in step as they were at 4.5.3.

Verified: cargo build for both crates is clean, cargo fmt --all --check
is silent, and cargo test -p fodid runs 103 tests with 0 failures.

* REVERT: Drop the version bump, the release branch already carries it

I bumped fodid to 4.5.4 here this morning, not having looked for an
existing release pull request. There is one, 31, opened on 2 September,
which moves the same line for the same reason and explains the same
mechanism, being that ci/publish-crates.sh skips a version already on
crates.io so a merge without a bump publishes nothing.

This branch now targets that release branch rather than main, so the
bump arrives from the base and duplicating it here would only conflict.

The fodid-cloud change goes with it. Pull request 31 reasoned that
fodid-cloud needs none, because its dependency reads a bare version
which Cargo treats as a caret requirement and so accepts 4.5.4, and that
is the author's decision to make rather than mine to override in
passing.

* Run the live cloud 51Did test in CI (#44)

* Add the 51Did client: two-step verification, redeem and the outcome types (#33)

* FEAT: Add the 51Did client, two-step verification, redeem and the outcome types

Every other 51Did language package carries a client for the two-step
verification, and Rust had none, so a Rust server could create a 51Did
and check its signature but could not take part in the verification at
all. This adds the fodid-client crate as a port of the .NET DidClient,
which is the model the Java, Node, Python and PHP ports follow.

The crate fetches and caches the published signing keys, verifies a
signature offline against the key in force when the identifier was
created, verifies a signature through the cloud, and redeems the sealed
creator context result a browser relays, reading the outcomes the other
packages report, misconfigured and invaliddate included. A factor of
misconfigured is read on its own and never falls through to a mismatch,
because it says the checking service could not determine that factor
and the identifier says nothing about it either way.

Every request goes through a DidHttpClient trait, and the crate builds
without a network stack by default so it compiles for wasm32-wasip1 and
an edge runtime can supply its own transport. The reqwest-client
feature turns on the built-in blocking reqwest transport, following the
cloud request engine. Credentials never travel in a URL, and the
licence key is sent only in the redeem form body.

Closes #32.

* FEAT: Make the 51Did client asynchronous with a non-Send transport

Every method of DidClient that may reach the network (public_keys,
public_key_for, verify_signature, verify_signature_detailed, verify,
verify_encoded, redeem, redeem_encoded) is now `pub async fn` with the
same name and return type, and the synchronous versions are gone. The
methods that never touch the network (resource_key, endpoint,
has_licence_key and the key selection helpers) are unchanged.

The DidHttpClient trait's one method now returns a LocalBoxFuture, a
boxed future that borrows the request and transport and is deliberately
not required to be Send. The crate carries no async runtime and no
async-trait dependency, so it builds for wasm32-wasip1 and a
single-threaded host such as a Trusted Server appliance can implement
the transport and await the client, which is the same shape the cloud
request engine's awaitable transport takes.

The built-in transport behind the reqwest-client feature is now the
asynchronous reqwest client with rustls and the form feature, keeping
the thirty second default timeout, zero meaning none, and the default
redirect handling the blocking client had.

The key cache keeps every rule the tests pin (fetch on first use, again
after a day, when no key covers the date, or when the date is past the
newest start) and now shares one in-flight fetch between concurrent
callers. A caller that finds a fetch in flight waits for it and answers
from the keys it landed, fetching for itself only when that fetch
failed, and a fetch dropped before it lands clears the in-flight mark so
no waiter is stranded. The lock is never held across an await.

The tests run on a tokio current-thread runtime as a dev-dependency,
with new cases for the shared fetch, a failed shared fetch, a dropped
fetch, and a transport whose future holds an Rc across an await to prove
the future need not be Send. The README and crate documentation show the
awaited calls and the new trait shape, and their examples remain
documentation tests.

* FIX: Publish the new crate, and stop its test reading internal constants

Two things this branch needed before it could go into the release.

ci/publish-crates.sh publishes from an explicit CRATES list in dependency
order, and fodid-client was not in it, so merging this would have added
the crate to the workspace and published nothing. It now sits directly
after fodid, which it depends on.

The fixture built a payload from fodid::HEADER_LENGTH and
fodid::MATCH_KEY_LENGTH. Pull request 38 took the offsets and lengths off
the public surface, because the only use a caller has for an offset is to
read a field out of the payload by hand and that is how the usage comes
out wrong. The fixture carries its own copy taken from the specification
instead, which is what the reader's own tests already do and is what a
test of a byte format should do anyway, since a payload built from the
reader's constants agrees with the reader whatever either of them says.

Verified: cargo fmt --all --check silent, cargo clippy on both crates
with -D warnings clean, cargo doc clean under RUSTDOCFLAGS=-D warnings,
and cargo test on both crates at 156 passed, 0 failed and 2 ignored,
those two being the live cloud tests.

* MERGE: Take the asynchronous client rewrite

The branch had moved on while this was in progress. The fixture constants
and the publish list entry both survive the merge unchanged.
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