@thg-agentic/hyper-personalisation-spec
v1.22.0
Published
The THG Hyper-Personalisation event specification: versioned JSON Schemas, the event registry, a validator, generated TypeScript, Kotlin, Swift and Java event models, the OpenAPI contracts for collection settings and the public API, and the onsite capture
Keywords
Readme
@thg-agentic/hyper-personalisation-spec
The event specification for THG Hyper-Personalisation. It defines every event a storefront, app or server sends, what each one carries, and who may send it. The same files drive the ingest's validation, the SDKs' generated models, and the warehouse's landing table, so "valid" means the same thing everywhere.
The spec is Segment-compatible on the wire: track, page and identify calls with the Segment
envelope, and since spec 1.2 every Segment Ecommerce V2 event, under its own name, with every
property Segment defines on it. Where this spec's shape differs from Segment's, the ingest folds
Segment's shape onto it before validation, so an integration written against Segment's documentation
is accepted unchanged. What still differs, and why, is on the developer docs' Segment compatibility page
(apps/docs/src/content/docs/integrations/segment-compatibility.mdx in the source repository).
What is in the package
| Path | What it is |
|---|---|
| schemas/core/<schema>/<M-R-A>.json | The JSON Schemas, one immutable file per version |
| schemas/RELEASED.json | sha256 of every released schema file |
| registry.json | Every event: its call type, its schema, which producers may send it, whether it is the shopper's own activity (activity), and which schema version each spec version uses |
| lib/index.mjs | The validator the ingest runs |
| generated/sdk/ | Event models for TypeScript, Kotlin, Swift and Java, with the spec's closed vocabularies (enums, context values, consent purposes), and a compact ruleset for in-browser conformance checks. Kotlin and Swift hold only the events an app may send |
| generated/destination/message.yaml | The Pub/Sub destination message as a field list (see The destination message) |
| assignment/holdout.json | Who is in a property's personalisation holdout: the algorithm and the vectors every SDK and server must reproduce |
| assignment/decision-id.json | The decision_id of a server-side assignment: the UUIDv5 namespace, the name format and the vectors HP and every client must reproduce |
| assignment/bandit-split.json | Which side of a readout slot's bandit-vs-default 50/50 split a visitor is on: the algorithm and the vectors every implementation must reproduce |
| assignment/experiment-arms.json | Which arm of an experiment a visitor is in, the one split every experiment on a property uses: the algorithm and the vectors every implementation must reproduce |
| examples/destination-message/ | Example destination messages, each validated by this package's tests |
| onsite/creative.schema.json | An onsite form's creative, the payload of each of its decision arms (JSON Schema 2020-12; see Onsite capture) |
| onsite/settings.schema.json | hp.onsite in the SDK settings: the live forms' triggers, pages, caps, consent text, questions and prize labels |
| onsite/values.json | The shopper values a form's text may show ({ "v": key, "fallback": … }): each value's key, its formats (the first its default) and the words the console offers it in |
| onsite/examples/ | Example creatives (*.creative.json) and settings (*.settings.json), each validated by this package's tests |
| lib/onsite.mjs (/onsite) | validateCreative and validateOnsiteSettings |
| openapi/collection.yaml | The collection host: the settings an SDK boots from |
| openapi/ingest.yaml | The collection host: the Segment-compatible endpoints events are sent to |
| openapi/public-api.yaml | The public API: recommendations, decisions, server-side assignments and slot registration, audience membership, social proof |
| EVENT-CATALOGUE.md | Every event and field, and what each page type owes, generated from the schemas |
CHANGELOG.md (what each spec version added, and the released descriptions it made wrong),
PRODUCT-IDS.md (which id is product_id and which is sku for variants, product pages and colour
families, with THG's mapping) and tests/vectors/ are in the repository, not the package.
Versions and what they guarantee
The spec is versioned <major>.<minor>, currently 1.22. Every SDK stamps it on each event:
{ "context": { "spec": { "version": "1.22" } } }Each schema is versioned <model>-<revision>-<addition> and has an $id of the form
urn:thg-hyper-personalisation:schema:<namespace>:<schema_name>:<M-R-A>, for example
urn:thg-hyper-personalisation:schema:core:order_completed:1-0-0. References between schemas use
these absolute ids. registry.json maps each spec version to the schema version every event and
shared definition uses in it.
The guarantees:
- A released schema never changes. Its bytes are checksummed in
schemas/RELEASED.json, and CI fails if one moves. A correction is a new version beside it. - A spec minor only adds. Between
1.0and1.1, a schema may gain optional properties and the registry may gain events. Nothing becomes required, nothing is removed or renamed, and no type, enum or bound narrows. An event valid under1.0is valid under every later1.x. An older validator still refuses a field it has not seen on an object that allows no others, so a minor that adds one is safe only for events that bypass the gateway, or once the gateway that knows it is released. - Breaking changes wait for a major. A new schema model — a required field, a rename, a removal — ships only in a new spec major.
- The previous major keeps working. The ingest validates each event against the major it declares, and accepts the current and the previous major, so an app still in the field on the old major keeps landing while it upgrades.
How an event's version is chosen:
context.spec.versionnames a released version: that version is used.- It names a minor this build has not seen (an SDK ahead of the ingest): the latest minor of the same major is used.
- It names a major that is neither current nor previous: the event is refused.
- It is absent: the current version is used, and the ingest records the version as inferred.
Who may send what
Each event lists its producers: web and mobile (a browser or app, holding a publishable key)
and server (a server integration, holding a secret key). The ingest takes the producer from the
key, never from the payload, and refuses an event from a producer not on its list. Most events
have one system of record — an order is reported by the service that placed it, a product view by
the page that showed it — because an event two systems both send under different messageIds is
counted twice. Where a server owns a fact the client also sees (sign-in, sign-up, sign-out,
newsletter opt-in, a completed checkout step, payment details), both may send it: under one
messageId the warehouse keeps the server's copy. Each event's producers_note says which.
The ingest also stamps fields of its own onto every accepted event (the organisation, source, key,
property, spec version, and when it arrived). Those are described by
schemas/core/server_envelope/1-0-1.json and are not part of what a client sends.
The destination message
What a Pub/Sub destination receives is a contract of its own, separate from what a producer sends:
schemas/core/destination_message/<version>.json, the version named by contracts in
registry.json. It describes the message data (the flattened row the platform's landing table
also receives) and the message attributes. The body is closed, so a new field is a new schema
version, and the gateway's tests fail if what it publishes drifts from the version named here.
| File | What it is for |
|---|---|
| schemas/core/destination_message/1-0-1.json | The JSON Schema. #/definitions/body and #/definitions/attributes validate each half alone |
| generated/destination/message.yaml | The same contract as a field list (name, type, optional, enum, doc), for consumers that keep message contracts in that form. Generated by pnpm run generate:destination; never edited |
| examples/destination-message/*.json | Example messages, { data, attributes, orderingKey? }, each validated by this package's tests |
validateDestinationMessage(message) checks a message against the current contract.
contracts.product_record names the catalogue record version on the wire, the body of a
kind=catalogue message, and validateProductRecord(record) checks one against it. It is also in
each spec version's definitions, frozen at 1-0-0: no event references it, so a new version moves
the contract rather than the spec.
Pinning it from another repository. Pin an exact package version and take the files from it, for example:
npm install --save-exact @thg-agentic/hyper-personalisation-spec@<version>
cp node_modules/@thg-agentic/hyper-personalisation-spec/schemas/core/destination_message/1-0-1.json \
node_modules/@thg-agentic/hyper-personalisation-spec/generated/destination/message.yaml \
contracts/hp/Commit what you copied together with the version, and in CI run your consumer (a router, a silver
model's extraction) over examples/destination-message/ from the same version. A consumer that
generates its own schema file from message.yaml should fail its build when the pinned file changes
and the generated one does not.
Onsite capture
An onsite form (a sign-up popup, a spin-to-win, a mobile sheet) is a decision slot of kind
capture, and each of its versions is an arm whose payload is a creative document. The creative
says what the form shows, step by step; it carries no HTML, CSS or script, its text is a small tree
of bold, italic and link runs, and its links and images are https only. Consent text, questions and
prize labels are not in the creative: the SDK settings publish them, so every arm shows the same
approved words. Odds and codes are in neither: prizes are drawn on the server.
import { validateCreative } from '@thg-agentic/hyper-personalisation-spec/onsite';
import creativeSchema from '@thg-agentic/hyper-personalisation-spec/onsite/creative.schema.json' with { type: 'json' };
const { valid, errors } = validateCreative(arm.payload);validateCreative runs the schema, then what JSON Schema cannot say: step ids are unique, every
step a creative names exists, each step has what its kind needs (an email step its field, the
consent and a submit button), and a whole heading or text stays within 120 or 600 characters.
validateOnsiteSettings checks the hp.onsite index. Both pull in Ajv, so keep them out of
browser bundles, as the event validator.
What each page type owes
page_expectations in the registry says which events a page of each context.page.type must
emit, so that a missing event is caught rather than merely an invalid one. It follows Segment's
lifecycle: a page call on every page, and on each commerce page the Ecommerce V2 event that
describes it.
| Page type | Required | Required on entry |
|---|---|---|
| list | page, Product List Viewed | |
| product | page, Product Viewed | |
| search | page, Products Searched | |
| basket | page, Cart Viewed | |
| checkout | page, Checkout Step Viewed | Checkout Started |
| every other type | page | |
An event required on entry is owed by the first page of a run of pages of that type, so checkout
sends Checkout Started once and Checkout Step Viewed on every step. EVENT-CATALOGUE.md also
lists the recommended events. The web SDK's conformance checker reports what a page did not send.
Extending the spec with your own events
The core catalogue is the same for every organisation. Your own events and context go in your organisation's namespace, which is its slug:
{ "type": "track", "event": "acme.Shade Finder Matched", "properties": { … } }
{ "context": { "extensions": { "acme": { "store_id": 410 } } } }Declare them in your organisation's tracking plan in the console: a JSON Schema per event name.
The ingest validates namespaced events and extension context against the tracking plan, and each
source's violation policy decides what happens to an event that does not match (quarantine it,
accept it with a flag, or drop the unplanned properties). A tracking-plan schema may reuse the
core definitions by $id, for example
{ "$ref": "urn:thg-hyper-personalisation:schema:core:product:1-0-0" }.
An event with no namespace that is not in the registry is refused, and so is a namespace the
sending source's organisation does not own. core is never an organisation namespace.
If an event would be useful to every retailer, propose it for the core spec instead.
Validating locally
npm install --save-dev @thg-agentic/hyper-personalisation-specimport { SPEC_VERSION, validate } from '@thg-agentic/hyper-personalisation-spec';
const result = validate(event, { producer: 'web' });
if (!result.valid) console.error(result.errors);
// result.specVersion is the version the event was judged by; result.specInferred is true when the
// event did not declare one.Options:
| Option | Meaning |
|---|---|
| producer | 'web', 'mobile' or 'server': refuse an event this producer may not send |
| specVersion | Validate against this version instead of the one the event declares |
| namespaces | Organisation namespaces the source may use. The validator checks the namespace only; the payload is checked against your tracking plan |
Use the validator in development and tests. It pulls in Ajv, so keep it out of production browser
bundles; the web SDK takes it as an injected validate function for that reason.
tests/vectors/ (in the repository, not the package) holds the conformance vectors: plain JSON events, each stating whether it is valid
and why. They are the portable definition of conformance, so an implementation in another language
can assert against them unchanged.
Generated models
generated/sdk/ is generated from the registry and the schemas and must not be edited by hand.
Each language's file exports SPEC_VERSION for stamping context.spec.version:
| Language | File | Package |
|---|---|---|
| TypeScript | typescript/events.ts | — |
| Kotlin | kotlin/CdpEvents.kt | com.thg.agentic.hyperpersonalisation.events |
| Swift | swift/CdpEvents.swift | — |
| Java | java/CdpEvents.java | com.thg.agentic.hyperpersonalisation.events |
Changing the spec
- Add a new version file beside the current one — never edit a released file — and point the registry's next spec version at it. An additive change is a new revision or addition in a new spec minor; a breaking change is a new model in a new spec major.
pnpm run release:schemasrecords the new file's checksum. It refuses a change to a released file, and a successor that does more than add optional properties.pnpm run generateregenerates the SDK models andEVENT-CATALOGUE.md.pnpm testruns the vectors, the consistency checks, and the release checks.
Licence
MIT. See LICENSE.
