Carry the terms a 51Did was created under, so they travel with it - #41
Draft
jwrosewell wants to merge 6 commits into
Draft
Carry the terms a 51Did was created under, so they travel with it#41jwrosewell wants to merge 6 commits into
jwrosewell wants to merge 6 commits into
Conversation
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.
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.
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.
… 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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft. The specification this implements has merged as 51Degrees/specifications#27, and this stays in draft until the cloud has been updated to write both fields. pipeline-dotnet#409 goes first, because the cloud is .NET and needs that package published before it can write anything.
What this is
Two fields of the 51Did payload, read by the
fodidcrate.The terms is one byte after the match key and before the creator context, saying which terms document the identifier was created under. A 51Did created for marketing may only be used by a receiver that has accepted the terms it was created under, so the identifier has to say which terms those are. In OpenRTB there is somewhere to put the answer, being the Terms Document Locator on the
eidsentry, and everywhere else there is not. An identifier passed as a query string parameter arrives on its own, and any hop can drop an answer carried beside it without the identifier looking any different, so the terms travel inside the identifier.The payload version is bits 4 and 5 of the flags byte, saying which layout the payload follows.
The terms
The byte is an index into a table the specification publishes and is not a version number. 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 exact document that was in force when the identifier was made, which an index that maps to one immutable document can answer years later and an address whose contents can be edited cannot.
FodId::termsanswers with the address of the document, asOption<&'static str>. The crate turns the index into the address, so a caller never handles the byte.FodId::terms0None1Some("https://m4ow.uk/mtm/2.txt")NoneThat one member is the whole of the addition to the public surface. The crate answers with the address and never fetches it, and the address is never an empty string and never built from the index. The
Termsenumeration keeps the names the specification gives for each index and ispub(crate), so it is no longer re-exported from the crate root.The payload version
This crate reads version 0 and refuses every other version with
Error::UnsupportedPayloadVersion { version }, 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. 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, which is worse than refusing. A version that nothing checks protects nothing.
The version is not exposed. Either this crate read the layout, in which case the accessors are the answer, or it did not, in which case there is no identifier to read fields from.
The parts that are easy to get wrong
No address is ever built from an index this crate cannot name. That would name a document nobody wrote, and a receiver would record having accepted terms that do not exist. An index of zero and an index added after this release therefore give a caller the same answer, which is deliberate, since both say the identifier does not give the terms and the answer has to come from somewhere else.
The terms byte is read at the offset the match key ends at. Where it sits moves with the match key length the identifier type requires, being offset 37 for a probabilistic or hashed email identifier and 21 for a random one. Reading it at a fixed offset would take a match key byte for a random identifier, so there is a test for both lengths.
A payload with no byte after the match key reads as index 0. The reader takes the byte with
payload.get(...).unwrap_or(0), so absence and zero need no telling apart, no presence flag exists and no format version is needed.IdType::Reservedanswers with no address. That type has no assigned match key length, and this crate reads every byte after the header as its value best effort, so there is no byte a reserved identifier could have its terms taken from. That is right rather than a fault, a comment at the read says so, a test pins it, and it stops being a special case as soon as a reserved type is assigned a length.No address does not mean the identifier is unrestricted. It says only that the identifier does not carry the answer, so the answer has to come from the surrounding protocol. Where an identifier may go is a separate question
FodId::usageanswers.The terms sits before the creator context section. An issuer writing a context section writes the terms byte before it, and this crate holds no upper bound on a payload, so a longer one is accepted and left in place.
The version is read on its own bits. A reader masking the wrong bits would refuse a version 0 identifier of some usages, or let a later version through, so there is a test over every combination of the usage and type bits.
Files changed
fodid/src/fodid.rs, theTermsenum (nowpub(crate)), theterms_indexfield onFodId, the version read and the terms read infrom_owid, and thetermsaccessor.fodid/src/error.rs,Error::UnsupportedPayloadVersion { version }with itsDisplayline.fodid/src/lib.rs, the crate documentation, the payload layout table, the worked example and the re-exports.fodid/README.md, the same documentation, whose code blocks are compiled and run as documentation tests.fodid/tests/fodid_tests.rs, the fixtures and the tests below.No offsets or lengths were added to the public surface, because the specification says a package must not expose them, and the version constant is
pub(crate). The offset of the terms byte is computed where it is needed. The constants already public onmainare left exactly as they are, since taking them off the surface is #38 and not this change.The test fixtures write what an issuer writes
canonical_payload()is the creating side, so it now carries the payload version 0 in the flags byte and the terms byte of the document a personalized marketing identifier is created under.payload_ending_at_match_key()is the fixture for a payload that carries no terms byte, which a reader takes as index 0 and which no issuer would write.Tests
All in
fodid/tests/fodid_tests.rs, each building its payload byte by byte the way the tests around them do, and signing a real envelope through the existingFixture.a_payload_ending_at_the_match_key_has_no_terms_addressan_explicit_zero_reads_the_same_as_a_missing_terms_byteindex_one_is_the_model_terms_for_marketing_and_carries_its_addressan_index_this_crate_does_not_know_has_no_address, covering 2, 127, 200 and 255no_terms_stated_and_an_unknown_index_both_have_no_addressevery_terms_index_decodes_as_the_specification_publishes_it, covering 0, 1, 2, 200 and 255the_terms_byte_is_read_after_the_match_key_for_both_match_key_lengthsa_creator_context_after_the_terms_leaves_both_the_match_key_and_terms_read, with 1, 40 and 300 bytes of context after the bytea_reserved_identifier_states_no_termsthe_terms_survive_a_base64_round_trippayload_version_zero_reads_every_fieldan_unassigned_payload_version_is_refused_and_names_the_versiona_refused_payload_version_names_itself_in_the_messagethe_payload_version_is_read_apart_from_the_usage_and_type_bitsThe address is written out in the test file rather than taken from the crate, so the test checks the crate against the specification rather than against itself, and it is pinned character for character as
https://m4ow.uk/mtm/2.txt.The terms table
The address, the index and the name are one table, so adding a terms document is one row and not a search for every place a number was written down. That is the point of the byte being an index rather than a version number, and the first version of this change did not honour it, spreading the same fact over three, being the variant, an arm of
from_indexand an arm ofurlplaces that could disagree.TERMS_TABLEnow holds one row per document carrying the index, the name and the address together, and both lookups read it. The bare0the absent byte fell back to is nowNOT_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
Unknownwith no address.What was run, and what it reported
On Windows, from the repository root, after
git submodule update --initandpwsh ./ci/copy-owid-source.ps1.cargo fmt -p fodid -- --checkcargo test -p fodidcargo clippy -p fodid --all-targets -- -D warningscargo build -p fiftyone-fodid-cloud --all-targetsNote for review
fodid/src/fodid.rsandfodid/README.mdpoint atdid-specification/identifier-layout.mdonmain, which will show the terms and version sections once 51Degrees/specifications#27 has merged, so this waits only on the cloud.fodid/src/fodid.rs, addingUsageand taking the raw flags byte and the offset constants off the public surface. Whichever merges second will need a small rebase, and nothing here works against what that one is doing.