@reopt-ai/data-contract
v0.15.0
Published
Wire contract (zod schemas + typed client) for the reopt-data ingest and query APIs
Readme
@reopt-ai/data-contract
The wire contract between reopt-data and its server-credential consumers: zod schemas for the ingest and query planes, plus a typed fetch client. One runtime dependency (zod), because consumers bundle this.
Defined by RFC-0023.
Subpaths
| Import | Contents |
| -------------------------------------------- | ---------------------------------------------------------------------- |
| @reopt-ai/data-contract | CONTRACT_VERSION, header names, DataApiError, shared primitives |
| @reopt-ai/data-contract/ingest | POST /api/track request + response schemas |
| @reopt-ai/data-contract/query | POST /api/v1/query/* request + response schemas |
| @reopt-ai/data-contract/control | /api/v1/organizations, /projects, /projects/{id}/events |
| @reopt-ai/data-contract/cli | browser-approved CLI auth and account response schemas |
| @reopt-ai/data-contract/catalog | reopt-data.events.json — the event catalogue as a file |
| @reopt-ai/data-contract/report | saved dashboard report specs (zReportSpec) |
| @reopt-ai/data-contract/identity | cookie and header names shared by the SDKs, the proxy and ingest |
| @reopt-ai/data-contract/events | automatic event names and property keys — no zod |
| @reopt-ai/data-contract/segment | segment (audience) definition v2 — schema, catalogue, validation |
| @reopt-ai/data-contract/definition | Project as Code — reopt-data/*.json file formats, defaults, upgrades |
| @reopt-ai/data-contract/integration | Installation lifecycle, public browser connection and runtime schemas |
| @reopt-ai/data-contract/integration-client | createIntegrationClient() — backend installation API |
| @reopt-ai/data-contract/client | createDataClient() |
| @reopt-ai/data-contract/ai | AI observability names, keys, limits, clamp, event id — no zod |
| @reopt-ai/data-contract/ai-schema | zod schemas for /ai properties and project settings |
/client and /integration-client reference fetch. If you bring your own transport, import
/query alone and you take on no fetch surface. /events has no zod either,
so a browser bundle that only needs the event names carries no schema code.
/cli defines the user-facing device-login wire contract. CLI bearer sessions
are a separate audience from browser cookie sessions and currently carry only
the account:read scope; deployment provisioning keys remain separate.
Segments
A segment is a saved set of actors, not a saved WHERE fragment. That
distinction is the whole reason /segment exists as its own subpath: the
definition is read by the console, the API, the CLI and the query compiler, and
four hand-synced copies of it is what the rebuild replaced.
import { validateSegmentDefinition, zSegmentDefinition } from "@reopt-ai/data-contract/segment";
const definition = zSegmentDefinition.parse(await response.json());
const issues = validateSegmentDefinition(definition);The tree is two levels, fixed: SegmentDefinition { logic, groups[] } →
Group { logic, criteria[] } → Criterion. Arbitrary depth costs a recursive
builder, recursive validation and a parenthesising compiler, and buys an
expressiveness nobody asks for.
Eleven criterion kinds, on a kind discriminator: performed,
not_performed, first_time, regularly, stopped, restarted, sequence,
property, segment (nested, depth 3), first_seen, static. Negation is a
negate flag rather than a parallel set of not_* kinds, so a rule that has to
reason about it reads one field instead of enumerating kinds.
CRITERIA_CATALOG is the single declaration of what a kind is — the fields
it may store, what it defaults to, and how it reads as a sentence. A form
layout, a save-time field whitelist and a summary chip that each carried their
own copy would drift, and the drift would look like a data bug. cleanCriterion
drops anything the catalogue does not declare, so a field the client invented
never becomes persisted state.
validateSegmentDefinition(definition, ctx?) runs on both sides. A rule the
client alone enforced would be bypassed by the API; a rule the server alone
enforced would surface as a failed save instead of an inline hint. It returns
every issue at once ({ path, code, message }) rather than throwing, because
the builder shows them all. Nested-reference cycles are found through a
ctx.resolveSegment callback — this package knows nothing about a database.
migrateSegmentDefinition(input) promotes v1 on read. It is an adapter, not
a data migration: nothing is rewritten until the next save, so a rollback loses
no segment. It is total by construction — unreadable input becomes an empty
definition with a warning, never a throw, because it runs on the read path of a
list endpoint where one malformed row must not take out the page.
describeCriterion(criterion, labels?) renders one line from the
catalogue's sentence tokens, so the summary a list row shows and the row
somebody edits cannot describe the same criterion differently. Korean copy for
every code and token ships in SEGMENT_MESSAGES.ko; pass labels to override.
SEGMENT_API_PATHS covers the read-only HTTP surface (list, get,
preview). Creating a segment means validating a definition against a
project's catalogue and then computing it — expensive work that belongs behind
a console somebody is looking at, not behind an unattended script.
Ingest modes
The credential decides the mode; there is no mode header to disagree with it.
| | browser (reopt-write-key) | server (reopt-client-id + reopt-client-secret) |
| --------------------------- | ---------------------------------------------------- | ---------------------------------------------------- |
| 5-second click dedup | yes | no |
| Idempotency | eventId | eventId — the only mechanism |
| Device identity | reopt-device-id header, else generated per request | event deviceId, else the header, else profileId |
| Sessions | per device | not created |
| A row that fails validation | fails the whole batch (400) | reported in rejected[]; the batch proceeds |
Server mode exists because a server batch legitimately repeats the same event name many times per second, and its "device" is a process rather than a person. Applying browser rules to it silently discards events and fabricates sessions.
Reconciling a batch
Every 2xx satisfies:
accepted + duplicates + rejected.length === events sentimport { reconcileIngestResponse } from "@reopt-ai/data-contract/ingest";
const response = await client.ingest.track(batch);
if (!reconcileIngestResponse(response, batch.length)) {
// The server dropped something without saying so — alert, do not advance.
}Rejections are permanent: the same row rejects the same way on resend. Count them as skipped and move the cursor forward.
Reading
import { createDataClient } from "@reopt-ai/data-contract/client";
const client = createDataClient({
baseUrl: process.env.REOPT_DATA_URL!,
clientId: process.env.REOPT_DATA_CLIENT_ID!,
clientSecret: process.env.REOPT_DATA_CLIENT_SECRET!,
});
const { data, meta } = await client.query.eventsTimeseries({
projectId,
startDate: "2026-08-01",
endDate: "2026-08-23",
granularity: "day",
eventName: "checkout_completed",
// Top-level event properties. Multiple filters are combined with AND.
propertyFilters: [{ key: "surface", operator: "eq", value: "brandfront" }],
breakdown: { kind: "property", key: "page_id", topN: 100 },
});propertyFilters currently supports exact string equality only. Breakdown
values are capped at 100 so page-scale tables stay bounded.
meta reports staleness as two separate numbers, never one sum, because they
have different remedies:
cacheAgeSeconds— how old the returned computation is. Resolves itself at the next TTL.ingestLagSeconds— how far behind live materialization was.0means no live event is pending;nullplusingestLagStatus: "unknown"means the measurement failed and must not be shown as fresh.ingestDeadLetterCount— pending events older than the live replay window. These remain visible without pegging the live "aggregating" indicator.
Not now - max(events.created_at): that conflates a lagging pipeline with a
quiet project and grows without bound while a project sits idle.
The event catalogue as a file
reopt-data.events.json declares what a project's events mean — display name,
conversion flag, status, the properties the hourly rollups break them down by —
next to the code that emits them. @reopt-ai/data-cli pushes it, so the
catalogue and the deploy are the same commit.
{
"$schema": "https://data.reopt.ai/schemas/event-catalog.v1.json",
"version": 1,
"projectId": "prj_123",
"events": {
"purchase": { "displayName": "Purchase", "conversion": true, "rollupProperties": ["plan"] },
"healthcheck": { "status": "internal" },
"$pageview": {},
},
}A field the file omits is the default, not "leave it alone." "$pageview": {}
declares an active, non-conversion event with no rollup properties — not an
event nobody has an opinion about. That is what lets verify answer whether
the catalogue matches the file at all; under "omitted means untouched" there is
no state the file could be said to describe. resolveEventCatalogEntry applies
the defaults, and both the CLI and the server read them from there so a diff
cannot invent drift on an event nobody touched.
Two fields are checked as a format on write, because nothing downstream can.
icon is a Lucide icon name in PascalCase (^[A-Z][A-Za-z0-9]*$) — the key the
console looks up, not the kebab-case spelling Lucide's docs use — and color is
a six-digit hex (^#[0-9a-fA-F]{6}$), the same form the console's colour picker
produces. Neither is checked against a list: an unknown-but-well-formed icon
still renders (the console falls back), and an icon set moves faster than a
released contract. What is refused is the value that was never plausible — a
URL, a sentence, rgb(…), a three-digit shorthand whose swatch breaks when the
UI appends its own alpha. Reads are not checked, so a value stored before this
rule still exports.
Writes go through PUT /api/v1/projects/{id}/events under compare-and-set: each
entry may carry the updatedAt the caller last saw, and one stale entry refuses
the whole batch with conflict and a details.conflicts list. Partial application
is what a caller cannot recover from — its baseline would then describe neither
the file it pushed nor the catalogue it pushed to.
Properties are observed, not declared
The catalogue file (version: 1) carries events and no properties, and that is
a decision rather than an omission.
An event is a name somebody chose and can be held to: declaring it in a file and
failing a build when the code emits something else is a useful contract. A
property is not. It appears the moment any client sends it, from a version of
the app that shipped months ago and is still running, and the catalogue's job is
to say what has actually been seen — which is why PropertyMeta is written by
ingest and read at GET /api/v1/projects/{id}/properties. A declared property
schema would make the file disagree with the data on every deploy, and the only
honest response to that disagreement would be to ignore it.
What a person does own is meaning: a property's description, whether pickers
offer it, and a correction to the type when the guess was wrong. Those three
columns — and only those — are what a version: 2 file adds:
{
"$schema": "https://data.reopt.ai/schemas/event-catalog.v2.json",
"version": 2,
"projectId": "prj_123",
"events": { "purchase": { "conversion": true } },
"properties": {
"event": { "plan": { "propertyType": "string", "description": "Plan code" } },
"profile": { "signup_source": { "hidden": true } },
},
}One rule is deliberately inverted against the event half. An event missing
from the file is archived; a property missing from the file is left exactly as
observed. Ingest is what makes a property exist, so retiring one here would
delete a row the next event recreates — and the file would be claiming an
authority it does not have. Inside a declared entry the usual rule still holds:
{} asserts no description and not hidden, so a console description the file
omits is cleared.
That asymmetry leaves a blind spot, and @reopt-ai/data-cli narrows it with two
opt-in flags on event verify whose reports never overlap:
--strict-properties— a property somebody described or hid in the console and never wrote down (isCuratedProperty). A real decision living outside the file.--strict-observed— a property ingest merely observed that the file never mentions. Excludes the SDK's own keys viaisSdkOwnedProperty: a$prefix is a reserved namespace, and an ordinary word likepathorvaluecounts as the SDK's only when every event that carried it is an automatic one. Avalueseen on a host'spurchaseis the host's.
--strict-observed cannot fail the commit that starts sending the key — the
server has not observed it when that commit's CI runs — so it fails the next,
unrelated run instead. That cost is why it is off by default and separate from
the flag above, rather than a wider reading of it.
A declarative property schema — one that could reject an unexpected key at
ingest rather than describe it afterwards — remains a different feature with a
different failure mode, and is not what version: 2 does.
Project as Code
The event catalogue was the first setting to live in the repository. /definition
extends the same treatment to the rest of a project's console settings — the
files under reopt-data/ that @reopt-ai/data-cli moves with
pull / diff / push / verify:
reopt-data/
project.json { "$schema", "version": 1, "projectId", "settings": { "timezone": "Asia/Seoul", "retentionDays": 90 } }
error-rules.json { "$schema", "version": 1, "projectId", "rules": [ { "key": "ignore-vendor", "name": "…", "action": { "kind": "suppression" }, "filters": [ … ] } ] }
reopt-data.lock.json the CLI's record of what the server held at the last syncThree rules carry over unchanged. The file is the truth: a setting the file
omits is the default, and the defaults live in resolveProjectSettings /
resolveErrorRuleEntry here and nowhere else, so the console, the server and a
push cannot disagree about a setting nobody touched. Writes are
compare-and-set: PATCH /api/v1/projects/{id} takes expectedUpdatedAt, and
PUT /api/v1/projects/{id}/error-rules takes one per rule; a stale value
refuses the whole request with conflict. Every file names its version,
and upgradeDefinition walks an older document to the current shape before it
is parsed — the CLI always writes the latest.
One rule is new, and it is what makes the console and the repository able to
share a project. Ownership is a key. A rule with a key belongs to the
repository: the file declares it, a push edits it, dropping it from the file
deletes it. A rule without a key belongs to the console and no push ever
touches it — it is listed, counted, and left alone. PATCH …/error-rules/{ruleId}
with { "key": "…" } hands a console rule to the repository (reopt-data import),
and { "key": null } hands it back (reopt-data unlink). A keyed rule the push
did not account for — somebody else imported it since the caller last pulled —
is refused as conflict with details.unexpected, because deleting it
silently is exactly the surprise the key exists to prevent.
Order is the file's array order. After a push, keyed rules are 0..n-1 in that
order and console-owned rules follow in their existing relative order, so the
evaluator's first-match tiebreak reads the same way the file does.
Risk policies live in definition: exhaustive project-field, error-rule-kind and
alert-field registries are shared with the CLI, and unknown changes are destructive.
Retention decreases, setting an inherited limit, and clearing an override require
explicit approval. Direct PATCH and atomic definition PUT enforce allowDestructive
on the server; the CLI additionally requires --yes.
Definition PUT accepts a UUID operationId. Identical retries return the original
transaction snapshot before CAS; different input reusing that ID returns 409
idempotency_conflict. GET .../definition/operations/:operationId recovers it.
Receipts are org-scoped, contain sensitive destinations and remain until project deletion.
Control errors expose classification, retryable and resolution (action/message),
which DataApiError and the CLI preserve.
client.control.applyProjectDefinition(projectId, input, auth) sends one
PUT /api/v1/projects/{id}/definition. Optional project, errorRules, and
alerts groups commit together; any CAS or validation failure rolls back all
of them. dryRun is controlled at the top level. The response contains
{ applied, snapshot: { project, errorRules, alerts } }, captured inside that
transaction. The CLI uses this snapshot as its lock baseline. Console CRUD,
import/unlink and REST writes share the parent-project lock protocol.
Nullable settings text is trimmed and blank text becomes null in the shared
resolver. For issue alerts, omitted unhandledOnly and false resolve equally.
Delivery bookkeeping changes lastTriggeredAt without changing the definition's
updatedAt.
Alerts, secrets and environments
reopt-data/alerts.json follows the same ownership rule (key, PUT
/api/v1/projects/{id}/alerts, PATCH …/alerts/{alertId}), with two
differences the file format carries.
A webhook URL is a secret and this file is committed, so destination may be
a ${env:NAME} reference — an upper-case environment variable name, as CI
secret stores spell them. resolveEnvReferences substitutes it and collects
every name the environment lacks, so a missing secret is reported once with
all its siblings rather than one CI run at a time. The server never sees a
reference: the CLI resolves before it sends, and alertDestinationIssues
rejects unresolved references and checks the resolved value by channel (an address for email, an https URL
for slack and webhook).
projectId is optional in every file. A repository that maps staging and
production to two projects keeps one set of files and names the project in
reopt-data.config (environments); the CLI keeps one lock per environment
(projectDefinitionLockFilename("staging") → reopt-data.staging.lock.json).
A file that does name a project is pinned to it, and a mismatch with the
environment's project is an error, not a guess.
zProject.definitionPushedAt / definitionPushedBy record the last push —
the whole-list writes and a PATCH that asserted expectedUpdatedAt; a host
binding a symbol app blind is not a push.
Errors
Non-2xx responses throw DataApiError with a stable code. Branch on code,
never on error/message — those are prose.
Note that the two 429s mean different things: rate_limited clears on its own,
quota_exceeded needs a human to raise the limit.
A 2xx whose body does not match the contract also throws, as
contract_mismatch, rather than being handed back half-understood.
Service integration
See the shared integration contract for authority boundaries, PKCE, binding CAS, public projections, managed credit allowances and rollout order. Package 0.11.0 retains analytics wire version 0.10.0; installation protocol version 1 has its own header.
Replay wire contract
The @reopt-ai/data-contract/replay subpath defines version 1 start/chunk schemas, event and byte limits, project settings, upload grants, privacy filtering, and gap-aware chunk metadata. Start accepts only a random document streamId; callers cannot supply project, device, profile or analytics session identity in the body. Chunk metadata is derived by the receiver. Replay payloads are never analytics event properties. This subpath is available in the workspace pending the next package release.
Replay public assets use ReplayPublicAsset, REPLAY_ASSET_LIMITS, and createReplayAssetResolver from /replay. The client sanitizer removes all resources unless an explicit resolver maps a reviewed static file. Server ingestion uses embeddedReplayAsset to revalidate only bounded PNG/JPEG/WebP/WOFF2 data URLs; external URLs remain blocked. These are additive v1 DOM payload capabilities. Pixel content and font metadata are not anonymized; consumer setup and exclusions are documented in the client README.
AI observability
@reopt-ai/data-contract/ai fixes the wire for LLM calls (RFC). Five events — $ai_generation (one model call), $ai_embedding, $ai_span (tool execution or custom section), $ai_trace (one top-level operation, sent once at its end) and $ai_feedback — carry $ai_* snake_case keys from AI_PROPERTIES. Every event but feedback needs $ai_trace_id and $ai_span_id; feedback targets a trace (optionally a span). Hosts' own properties ride along untouched.
Three rules are shared rather than left to each side:
- One clamp.
clampAiProperties()is what the SDK runs before sending and what ingest runs on receipt, so both cut at the same place (AI_LIMITS). It is lenient: a mistyped$ai_*key is dropped instead of failing the event, counts are floored to non-negative integers, content is JSON-serialized, truncated without splitting a surrogate pair, and flagged with$ai_content_truncated. - Server-owned cost.
$ai_cost_usdand$ai_cost_source(AI_SERVER_OWNED_PROPERTIES) are removed from client input and recomputed by ingest. A client passes what it was told — for example by AI Gateway — as$ai_reported_cost_usd. - Deterministic ids.
aiEventId(traceId, spanId, kind)is a UUIDv5 computed with a bundled SHA-1, so node, edge and browsers produce the same id. Server-mode ingest dedups on event id, so a hook that fires twice or a retried batch lands once.
Prompt and response content ($ai_input/$ai_output) is off by default: it is stored only when both the SDK option and the project's captureContent setting are on. /ai has no zod so the SDK's telemetry integration stays schema-free; /ai-schema adds lenient zod schemas (unknown keys kept) and re-exports /ai. Analytics wire version is unchanged — the additions are additive.
