From d8b3126782cc5be9ccd2f2296677455f544d7bdd Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 02:17:35 -0700 Subject: [PATCH] Add OpenABTest draft experiment and accounting contracts (#180) --- README.md | 9 + .../contract/spec-discovery.contract.test.ts | 1 + apps/logicsrc-web/src/lib/specs.ts | 1 + docs/openabtest.md | 140 ++++++ package-lock.json | 8 +- .../fixtures/openabtest/adjustment.json | 22 + .../fixtures/openabtest/assignment.json | 15 + .../fixtures/openabtest/chovy-manifest.json | 69 +++ .../fixtures/openabtest/conversion.json | 26 + .../fixtures/openabtest/eligibility.json | 14 + .../schemas/fixtures/openabtest/exposure.json | 16 + .../openabtest/reconciliation-pending.json | 27 + .../fixtures/openabtest/reconciliation.json | 30 ++ packages/schemas/package.json | 11 +- .../logicsrc-openabtest-event.schema.json | 460 ++++++++++++++++++ .../logicsrc-openabtest-manifest.schema.json | 291 +++++++++++ packages/sdk/README.md | 10 + packages/sdk/package.json | 2 +- packages/sdk/src/index.ts | 3 + packages/sdk/src/openabtest.ts | 81 +++ packages/validators/package.json | 6 +- packages/validators/src/index.ts | 5 + packages/validators/src/openabtest.test.ts | 103 ++++ packages/validators/src/openabtest.ts | 68 +++ packages/validators/src/schemas.ts | 4 + 25 files changed, 1410 insertions(+), 12 deletions(-) create mode 100644 docs/openabtest.md create mode 100644 packages/schemas/fixtures/openabtest/adjustment.json create mode 100644 packages/schemas/fixtures/openabtest/assignment.json create mode 100644 packages/schemas/fixtures/openabtest/chovy-manifest.json create mode 100644 packages/schemas/fixtures/openabtest/conversion.json create mode 100644 packages/schemas/fixtures/openabtest/eligibility.json create mode 100644 packages/schemas/fixtures/openabtest/exposure.json create mode 100644 packages/schemas/fixtures/openabtest/reconciliation-pending.json create mode 100644 packages/schemas/fixtures/openabtest/reconciliation.json create mode 100644 packages/schemas/schemas/logicsrc-openabtest-event.schema.json create mode 100644 packages/schemas/schemas/logicsrc-openabtest-manifest.schema.json create mode 100644 packages/sdk/src/openabtest.ts create mode 100644 packages/validators/src/openabtest.test.ts create mode 100644 packages/validators/src/openabtest.ts diff --git a/README.md b/README.md index a989608..bcdbef0 100644 --- a/README.md +++ b/README.md @@ -189,3 +189,12 @@ It provides read-only resources for docs and schemas, validation/example tools, - uGig as the default jobs and gigs marketplace plugin. - c0mpute as a work-in-progress compute jobs and worker pools plugin. - Installer, update/upgrade, remove/uninstall workflows. + +## OpenABTest draft + +[OpenABTest](docs/openabtest.md) defines reusable experiment manifests and private +eligibility, assignment, exposure, conversion and accounting events. The Chovy +example compares 5%, 10% and 20% discounts on every referred purchase, with +sticky customer assignment and affiliate payout withheld until actual costs +and fees are reconciled. Schemas, validators and SDK constructors ship in 0.3.0; +assignment, analytics and payment runtimes remain the integrating service's job. diff --git a/apps/logicsrc-web/contract/spec-discovery.contract.test.ts b/apps/logicsrc-web/contract/spec-discovery.contract.test.ts index 96ffca8..fc70a61 100644 --- a/apps/logicsrc-web/contract/spec-discovery.contract.test.ts +++ b/apps/logicsrc-web/contract/spec-discovery.contract.test.ts @@ -9,6 +9,7 @@ import sitemap from "../src/app/sitemap"; vi.mock("../src/lib/supabase", () => ({ publicClient: () => { throw new Error("offline"); } })); describe.each([ + { slug: "openabtest", name: "OpenABTest", family: "process" }, { slug: "openfleet", name: "OpenFleet", family: "process" }, { slug: "openrental", name: "OpenRental", family: "catalogs" }, { slug: "openwall", name: "OpenWall", family: "people" } diff --git a/apps/logicsrc-web/src/lib/specs.ts b/apps/logicsrc-web/src/lib/specs.ts index 5ed5e85..4b7c0a2 100644 --- a/apps/logicsrc-web/src/lib/specs.ts +++ b/apps/logicsrc-web/src/lib/specs.ts @@ -105,6 +105,7 @@ export const FAMILIES: Family[] = [ "The lifecycle for building software when agents work in parallel and CI is the only gate, the requirement document an agent can execute, the settlement and proof layer under a peer-to-peer swarm, a lossless byte-stream envelope, the five nouns a shared ontology needs, and the record an agent session carries about who spawned it and under what ceiling.", specs: [ s("asdlc", "ASDLC", "The Agentic Software Development Lifecycle: nine phases, four conformance levels and the ratchet rule"), + s("openabtest", "OpenABTest", "Portable experiments with sticky assignments, distinct exposure and conversion events, and reconciled profit accounting", { landing: undefined, status: "draft" }), s("openprd", "OpenPRD", "A product requirement document an agent can execute and a person can read"), s("openswarm", "OpenSwarm", "Settlement and proof of work done under a peer-to-peer swarm"), s("openstream", "OpenStream", "A lossless byte-stream relay envelope, with benchmark reports per release", { landing: undefined }), diff --git a/docs/openabtest.md b/docs/openabtest.md new file mode 100644 index 0000000..02f9b05 --- /dev/null +++ b/docs/openabtest.md @@ -0,0 +1,140 @@ +# OpenABTest + +OpenABTest is a portable experiment manifest and private event contract for comparing product offers across services without confusing assignment, exposure, purchases, or profit. + +Status: **0.1 draft proposal**, September 13, 2026. This release supplies JSON Schemas, semantic validators, SDK document constructors and examples. It does not ship an assignment service, analytics warehouse, statistical decision engine, payment executor, or registered AT Protocol extension. No external standards body has accepted this draft. + +Slug: `openabtest` + +## The contract + +A manifest names one experiment, its versioned variants, eligibility rule, randomization unit, observation window, metrics and guardrails. A private append-only event stream records what happened. Consumers can export the same contract from different products while keeping identity, authorization and settlement in the service that owns them. + +- Manifest schema: `@logicsrc/schemas/openabtest-manifest`. +- Event schema: `@logicsrc/schemas/openabtest-event`. +- Validation: `validate("openabtest-manifest", document)` and `validate("openabtest-event", document)` from `@logicsrc/validators` 0.3.0 or newer. +- SDK: `OpenABTestManifest`, `OpenABTestEvent`, `createOpenABTestManifest` and `createOpenABTestEvent` from `@logicsrc/sdk` 0.3.0 or newer. Constructors add the draft discriminator; they do not validate, assign people, authenticate events or initiate payment. +- Complete examples: [manifest](https://github.com/profullstack/logicsrc/blob/master/packages/schemas/fixtures/openabtest/chovy-manifest.json) and [event fixtures](https://github.com/profullstack/logicsrc/tree/master/packages/schemas/fixtures/openabtest). + +Schema `$id` values name the contracts; they do not imply that a schema-hosting endpoint or universal discovery API has been deployed. A service may link a public, redacted manifest from its documentation. Participant events and settlement evidence MUST remain private. + +## Manifest and versioned assignment + +Required fields are `openabtest: "0.1-draft"`, `id`, positive integer `revision`, `name`, `state`, `cohort`, `assignment`, `variants`, `window`, `metrics`, and `guardrails`. `economics` is optional for experiments that do not change prices. IDs are issuer-scoped opaque strings, at most 128 characters. Variant IDs must be unique within the manifest; weights are positive safe integers with a safe-integer sum. The share of newly assigned eligible participants is `weight / sum(weights)`, not a promise of an exact observed sample split. + +`cohort` contains a stable `id`, an explicit `eligibilityRule`, and `purchaseScope`. Rules must state the product, tenant, referral validity, exclusions, geography or other restrictions when applicable. They are prose for the implementing service, not executable expressions. The Chovy scope is **all-referred-purchases**. Other products may select `all-eligible-purchases`, `first-eligible-purchase`, or `custom` with a complete rule. Overlapping experiments must declare their interaction policy in the eligibility rule; do not silently stack price discounts. + +`assignment` names the randomization `unit` (user, account, session, device or a documented custom unit), `authority: "server"`, versioned algorithm and key names, and `persistence: "sticky"`. Pricing assignment MUST happen on the authorized server after eligibility is verified. Persist the first assignment atomically with a uniqueness constraint over the experiment/cohort and stable participant identity. Concurrent requests, devices, checkout retries and later purchases read that assignment. A client-supplied variant, referral claim or price is never authoritative. + +The algorithm is a versioned implementation profile, not a required cross-service hashing algorithm. A service using the example `hmac-sha256-persisted-v1` profile must document canonical hash inputs, integer bucket mapping, key-version handling and test vectors privately where needed; raw secret keys never enter the manifest. A cryptographic server random draw followed by durable storage is also valid under its own profile name. Weight changes apply only to new assignments; do not rehash existing customers into a new price. + +Variant IDs, weights, parameters, eligibility, assignment profile, economics and metric definitions are immutable within a revision. Changing one requires a new revision; archived revisions remain addressable. Existing assignments continue to reference their original manifest revision. A migration to a new offer requires an explicit separately recorded policy and cannot rewrite an accepted price or accrued commission. State transitions are administrative updates recorded in the issuer's audit log, not variant changes. + +`window.startsAt` is inclusive and `endsAt` exclusive. It defines enrollment/reporting boundaries, not permission to discard delayed refunds. A service must record event time and ingestion time separately in its ledger and describe the cutoff and late-event policy in reports. All timestamps are RFC 3339. Currency codes use three uppercase letters; support and exponent must be verified by the integrating product, not inferred from a regex. + +## Chovy: 5%, 10% and 20% on every referred purchase + +The complete fixture is an illustrative draft, not evidence that a live experiment started on its example dates. Its operational sample and duration floors are examples, not a power calculation or guarantee of statistical significance. + +```json +{ + "id": "chovy-signup-discount-v1", + "revision": 1, + "cohort": { + "id": "eligible-referred-customers", + "eligibilityRule": "Server-verified eligible referral; exclude self-referrals and invalid attribution. Every referred purchase qualifies.", + "purchaseScope": "all-referred-purchases" + }, + "assignment": { + "unit": "user", "authority": "server", "keyVersion": "1", + "algorithm": "hmac-sha256-persisted-v1", "persistence": "sticky" + }, + "variants": [ + { "id": "signup-5", "weight": 1, "parameters": { "discountBps": 500 } }, + { "id": "signup-10", "weight": 1, "parameters": { "discountBps": 1000 } }, + { "id": "signup-20", "weight": 1, "parameters": { "discountBps": 2000 } } + ] +} +``` + +This is a manifest excerpt; use the linked full fixture for validation. The `signup-*` IDs are stable identifiers despite the discount applying to **every referred purchase**, including later apps and projects. There is no first-purchase or single-project lock. Recheck purchase eligibility without rerandomizing the customer. An invalid referral does not earn a discount or a payout merely because an assignment exists. + +The example uses USD cents, a $400 list price per agent-hour, a **modeled, unverified** $100 cost per hour and a $50 minimum retained profit per hour. These inputs are assumptions supplied for this example; actual costs and fees must be reconciled. The previous fixed 80% or 10% affiliate proposals are not part of this contract. + +| Variant | Discount | Customer price/hour | Modeled cost/hour | Minimum retained/hour | Affiliate remainder before fees/hour | +| --- | ---: | ---: | ---: | ---: | ---: | +| signup-5 | 5% | $380 | $100 | $50 | $230 | +| signup-10 | 10% | $360 | $100 | $50 | $210 | +| signup-20 | 20% | $320 | $100 | $50 | $170 | + +Use integer minor units: list `40000`, modeled cost `10000`, minimum retained profit `5000`. `discountBps` uses basis points: 500, 1000 and 2000. Costs, fees, refunds and taxes borne by the operator reduce the available remainder. They must not be omitted to manufacture a positive result. Unknown costs are `null`, never assumed to be zero. + +For settled usage, the available affiliate amount is `max(0, net recognized revenue - actual costs - actual fees - minimum retained profit)`. The minimum is scaled to actual billed usage, rounding the required floor upward to the next minor unit. If net revenue cannot cover costs, fees and the floor, record the shortfall, withhold new payout and pause new assignments. A price must not be represented as guaranteeing profit while expenses are unknown. The manifest's modeled cost is for comparison and never authorizes a payout. + +## Events, offers and attribution + +Every event has `openabtest`, unique `id`, `producerId`, `manifestId`, `manifestRevision`, opaque `participantId`, `at`, `kind` and a typed `payload`. + +| Kind | Payload and meaning | +| --- | --- | +| eligibility | `cohortId`, `eligible: true`; the server verified entry into the cohort. Repeated visits do not add people to the denominator | +| assignment | `cohortId`, `assignmentId`, `variantId`; a durable choice exists. This does not assert that the customer saw it | +| exposure | `assignmentId`, `variantId`, `offerId`, `surface`; the offer was actually shown on the named surface. Rendering failure is not an exposure | +| conversion | `assignmentId`, `variantId`, unique `conversionId`, immutable `price`; an authenticated purchase confirmation, not a checkout click or a client success redirect | +| adjustment | Assignment and conversion IDs, unique `adjustmentId`, currency, signed `amountMinor`, reason and evidence references; a refund, chargeback or accounting correction | +| reconciliation | Assignment and conversion IDs, increasing `accountingRevision`, currency, recognized revenue/refunds, actual costs/fees, affiliate allocation, retained profit, floor, state, payout status and evidence references | + +A conversion `price` freezes `offerId`, `acceptedAt`, currency, list and charged unit price, discount basis points, `quantityMilliUnits`, `rounding: "half-up"` and total. One agent-hour is 1000 milli-units. Round the discounted unit price half up, then multiply by quantity and round the total half up. The validator checks those arithmetic relations using integer arithmetic. Products needing other tax or rounding profiles must define a later compatible profile; do not silently change these equations. Honor the accepted snapshot through checkout even if new assignments pause or a manifest revision changes. The server checks the quote belongs to the participant and assignment, is eligible, has not expired, and matches the order and currency before confirmation. + +Authorize all event ingestion. Authenticate producers with deployment-managed scoped credentials; verify their authority for the experiment and event type. Client exposure observations may be accepted only through the server after binding them to a server-issued offer. Only verified purchase, refund and accounting sources may produce corresponding authoritative events. JSON `producerId` alone proves nothing. + +Deduplicate events by `(producerId, id)` and immutable payload binding. An identical retry is a no-op; the same key with changed content is a conflict. Deduplicate assignment by its ledger key, purchases by the merchant's immutable conversion ID, and adjustments by the adjustment ID even when a different callback event ID is used. A conversion belongs to one participant, assignment, offer and original referral attribution; retries or a subsequent referral link cannot steal it. Keep external order/provider IDs in the private mapping if they are not safe opaque identifiers. + +Consumers verify manifest revision and variant existence, participant/assignment ownership, currency, original conversion attribution, event order and accounting revision against the private ledger. Out-of-order events can be buffered and reconciled; never invent missing eligibility or exposure. An authenticated conversion can exist without an observed exposure due to instrumentation loss; report that discrepancy. Standalone schema validation cannot establish these cross-record facts or event authority. + +## Reconciliation, refunds and payment references + +A pending reconciliation has unknown affiliate/retained amounts (`null`) and `payoutStatus: "withheld"`; actual costs and fees may also be `null`. A reconciled record requires all those numbers and private proof references. Its equality is: + +```text +retainedProfitMinor = revenueMinor - refundsMinor + - actualCostMinor - feesMinor - affiliateMinor +``` + +`revenueMinor` is the recognized original purchase revenue, `refundsMinor` its cumulative revenue reversal, and actual costs/fees include all reconciled operator expenses for that purchase. `retainedProfitMinor` may be negative. Refunds and chargebacks are negative adjustment amounts. Corrections may have either sign. A reconciliation is a new complete accounting snapshot at a larger revision, not an additional purchase or an amount to sum with every earlier snapshot. + +`payoutStatus: "eligible"` only says the stated reconciliation satisfies the local profit floor. It is **not** evidence that a payout occurred, permission to initiate one, or a guarantee that the evidence is genuine. A payout service must independently recheck evidence, eligibility, settlement finality, liabilities and the retained floor. `proofRefs` are opaque references resolved inside an authorized evidence store; no bearer URLs, tokens, addresses or payment instructions are allowed. CoinPay or another settlement adapter may keep authenticated receipt references there; this draft does not prescribe or call a payment API. + +Later refunds append adjustment events and another reconciliation against the same original assignment and currency. Reports recompute the conversion's latest verified net accounting rather than counting a second conversion. Preserve historical accepted prices and accrued or paid commission entries. Post explicit offset/liability entries for corrections under the accepted affiliate agreement instead of overwriting history or automatically debiting an affiliate. A zero new allocation after a shortfall does not erase an earlier accrual. Cross-currency settlement needs explicit conversion evidence outside this draft; do not add USD and another currency together. + +## Metrics and decision rules + +Every report states the manifest revision(s), assignment unit, cohort and exclusions, event-time window, observation/ingestion cutoff, attribution window, maturity delay, currency and sample counts. Show eligible, assigned, exposed, converted and reconciled participant counts separately for every variant, plus assignment failures, unknown variants and pending/unreconciled conversions. Report unique counts and purchase counts so repeat purchases are visible. + +The denominator per variant is unique eligible participants with a valid persisted assignment in the enrollment window, including those who never saw an offer or purchased. Also report eligible-but-unassigned participants and why they are unassigned; do not silently drop failures. Assignment and exposure are not conversions. The randomization unit in the Chovy example is a customer, so repeated purchases are correlated observations within that customer, not independent experimental subjects. + +- **Conversion rate:** unique assigned eligible participants with at least one confirmed purchase in the stated attribution window / unique assigned eligible participants. Report refunded-only customers separately and give the chosen net-conversion interpretation; do not silently change it between variants. +- **Retained profit per eligible visitor:** sum the latest reconciled net retained profit of all attributed purchases, including refund effects, / the same eligible-participant denominator. The historical metric name says visitor; the denominator is the manifest's randomization unit, users in this example. Never substitute per-purchaser profit or gross revenue. + +When the denominator is zero, display **no data**, not 0% success or a winning variant. Missing accounting is reported as **pending/incomplete**, with the number and value of unreconciled purchases. A partial reconciled numerator may be shown only with that label and its coverage; it is not a complete profit comparison. State assignment/exposure imbalance, instrumentation failures and exclusions before comparing results. + +`minimumEligiblePerVariant` and `minimumObservationSeconds` are predeclared operational floors. Meeting them does not establish statistical significance. `winnerPolicy: "manual-review"` forbids automatic winner declarations from this contract alone, especially with tiny samples or no data. A decision requires a separately documented analysis plan, uncertainty estimates, accounting completeness and reviewed guardrails. Repeated peeking or changing metrics does not justify a winner claim. This release intentionally provides no statistics runtime. + +## Lifecycle and guardrails + +| State | Allowed behavior | +| --- | --- | +| draft | Validate and review; no live enrollment | +| running | Enroll eligible participants within the window, record durable assignments and serve bound offers | +| paused | Stop new assignments and new experimental offers; honor already accepted offers and keep ingesting outcomes, refunds and reconciliation | +| closed | Terminal for enrollment; retain assignments, accepted offers, evidence and audit history, and process later outcomes and adjustments | + +A paused experiment may resume under the same immutable definition with a recorded operator action. A closed experiment cannot resume; a new experiment/revision and explicit migration policy is required for future offers. Closing or pausing never changes a historical accepted price or earned commission. Existing customers' future recurring-discount rights follow the terms accepted by the customer, even while new enrollment is paused or closed; such entitlements are not revoked by this lifecycle state. + +Guardrails include the retained-profit floor, complete actual-cost evidence before payout, quote consistency, authentication failures, attribution duplication, missing events, unreasonable sample imbalance and product-specific abuse or budget limits. On breach, pause new assignments, record the reason and owner, withhold affected new payouts, and investigate. Resume is an authorized operator decision with a recorded reason. The manifest is not a command that bypasses the host's authorization policy. + +## Privacy and conformance boundary + +Use purpose-scoped opaque participant, producer, order and evidence IDs. Keep the identity mapping in the source service with access control and a documented retention/deletion policy. Do not export names, emails, IP addresses, credentials, raw referral codes, payment tokens, prompts or transcripts. Avoid tiny public cohorts that could reveal individual behavior. Public reports should be aggregated with the deployment's privacy rules; individual events remain private. + +JSON Schema validates shape, ranges, required fields and unknown properties. `@logicsrc/validators` additionally rejects duplicate variant IDs, unsafe weight sums, reversed windows, invalid discounts, inconsistent accepted prices, invalid refund signs, incomplete reconciled accounting and payout allocations that breach the floor. It does not verify real-world costs, ledger ownership, signatures, assignment stability, idempotency storage, referral entitlement or historical preservation. An implementation must enforce those requirements before claiming operational conformance. diff --git a/package-lock.json b/package-lock.json index b7bceb6..bd5167b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9105,12 +9105,12 @@ }, "packages/schemas": { "name": "@logicsrc/schemas", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT" }, "packages/sdk": { "name": "@logicsrc/sdk", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "devDependencies": { "vitest": "^4.0.8" @@ -9130,10 +9130,10 @@ }, "packages/validators": { "name": "@logicsrc/validators", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { - "@logicsrc/schemas": "^0.2.0", + "@logicsrc/schemas": "^0.3.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "yaml": "^2.8.1" diff --git a/packages/schemas/fixtures/openabtest/adjustment.json b/packages/schemas/fixtures/openabtest/adjustment.json new file mode 100644 index 0000000..efa1b89 --- /dev/null +++ b/packages/schemas/fixtures/openabtest/adjustment.json @@ -0,0 +1,22 @@ +{ + "openabtest": "0.1-draft", + "producerId": "chovy-server", + "manifestId": "chovy-signup-discount-v1", + "manifestRevision": 1, + "participantId": "participant-opaque-7d12", + "at": "2026-09-15T12:00:00Z", + "id": "event-adjustment-42", + "kind": "adjustment", + "payload": { + "assignmentId": "assignment-42", + "variantId": "signup-10", + "conversionId": "purchase-42", + "adjustmentId": "refund-42", + "currency": "USD", + "amountMinor": -3600, + "reason": "refund", + "proofRefs": [ + "refund-proof-42" + ] + } +} diff --git a/packages/schemas/fixtures/openabtest/assignment.json b/packages/schemas/fixtures/openabtest/assignment.json new file mode 100644 index 0000000..3a6c05e --- /dev/null +++ b/packages/schemas/fixtures/openabtest/assignment.json @@ -0,0 +1,15 @@ +{ + "openabtest": "0.1-draft", + "producerId": "chovy-server", + "manifestId": "chovy-signup-discount-v1", + "manifestRevision": 1, + "participantId": "participant-opaque-7d12", + "at": "2026-09-15T12:00:00Z", + "id": "event-assignment-42", + "kind": "assignment", + "payload": { + "assignmentId": "assignment-42", + "variantId": "signup-10", + "cohortId": "eligible-referred-customers" + } +} diff --git a/packages/schemas/fixtures/openabtest/chovy-manifest.json b/packages/schemas/fixtures/openabtest/chovy-manifest.json new file mode 100644 index 0000000..03fbbf0 --- /dev/null +++ b/packages/schemas/fixtures/openabtest/chovy-manifest.json @@ -0,0 +1,69 @@ +{ + "openabtest": "0.1-draft", + "id": "chovy-signup-discount-v1", + "revision": 1, + "name": "Chovy referred purchase discount", + "state": "draft", + "cohort": { + "id": "eligible-referred-customers", + "eligibilityRule": "Server-verified eligible referral; exclude self-referrals and invalid attribution. Every referred purchase qualifies.", + "purchaseScope": "all-referred-purchases" + }, + "assignment": { + "unit": "user", + "authority": "server", + "keyVersion": "1", + "algorithm": "hmac-sha256-persisted-v1", + "persistence": "sticky" + }, + "variants": [ + { + "id": "signup-5", + "weight": 1, + "parameters": { + "discountBps": 500 + } + }, + { + "id": "signup-10", + "weight": 1, + "parameters": { + "discountBps": 1000 + } + }, + { + "id": "signup-20", + "weight": 1, + "parameters": { + "discountBps": 2000 + } + } + ], + "window": { + "startsAt": "2026-09-14T00:00:00Z", + "endsAt": "2026-10-14T00:00:00Z" + }, + "metrics": { + "primary": "retained-profit-per-eligible-visitor", + "denominator": "unique-eligible-participants", + "conversion": "unique-participants-with-confirmed-purchase", + "profit": "reconciled-net-retained-profit", + "minimumEligiblePerVariant": 100, + "minimumObservationSeconds": 604800, + "winnerPolicy": "manual-review" + }, + "guardrails": { + "preserveAcceptedOffers": true, + "preserveAccruedCommissions": true, + "requireReconciledCostsForPayout": true, + "onBreach": "pause-new-assignments" + }, + "economics": { + "currency": "USD", + "minorUnitExponent": 2, + "unit": "agent-hour", + "listUnitPriceMinor": 40000, + "modeledUnitCostMinor": 10000, + "minimumRetainedUnitProfitMinor": 5000 + } +} diff --git a/packages/schemas/fixtures/openabtest/conversion.json b/packages/schemas/fixtures/openabtest/conversion.json new file mode 100644 index 0000000..d2bc8e2 --- /dev/null +++ b/packages/schemas/fixtures/openabtest/conversion.json @@ -0,0 +1,26 @@ +{ + "openabtest": "0.1-draft", + "producerId": "chovy-server", + "manifestId": "chovy-signup-discount-v1", + "manifestRevision": 1, + "participantId": "participant-opaque-7d12", + "at": "2026-09-15T12:00:00Z", + "id": "event-conversion-42", + "kind": "conversion", + "payload": { + "assignmentId": "assignment-42", + "variantId": "signup-10", + "conversionId": "purchase-42", + "price": { + "offerId": "offer-42", + "acceptedAt": "2026-09-15T11:59:00Z", + "currency": "USD", + "listUnitPriceMinor": 40000, + "chargedUnitPriceMinor": 36000, + "discountBps": 1000, + "quantityMilliUnits": 1000, + "rounding": "half-up", + "totalMinor": 36000 + } + } +} diff --git a/packages/schemas/fixtures/openabtest/eligibility.json b/packages/schemas/fixtures/openabtest/eligibility.json new file mode 100644 index 0000000..882211d --- /dev/null +++ b/packages/schemas/fixtures/openabtest/eligibility.json @@ -0,0 +1,14 @@ +{ + "openabtest": "0.1-draft", + "producerId": "chovy-server", + "manifestId": "chovy-signup-discount-v1", + "manifestRevision": 1, + "participantId": "participant-opaque-7d12", + "at": "2026-09-15T12:00:00Z", + "id": "event-eligibility-42", + "kind": "eligibility", + "payload": { + "cohortId": "eligible-referred-customers", + "eligible": true + } +} diff --git a/packages/schemas/fixtures/openabtest/exposure.json b/packages/schemas/fixtures/openabtest/exposure.json new file mode 100644 index 0000000..f4497ad --- /dev/null +++ b/packages/schemas/fixtures/openabtest/exposure.json @@ -0,0 +1,16 @@ +{ + "openabtest": "0.1-draft", + "producerId": "chovy-server", + "manifestId": "chovy-signup-discount-v1", + "manifestRevision": 1, + "participantId": "participant-opaque-7d12", + "at": "2026-09-15T12:00:00Z", + "id": "event-exposure-42", + "kind": "exposure", + "payload": { + "assignmentId": "assignment-42", + "variantId": "signup-10", + "offerId": "offer-42", + "surface": "checkout" + } +} diff --git a/packages/schemas/fixtures/openabtest/reconciliation-pending.json b/packages/schemas/fixtures/openabtest/reconciliation-pending.json new file mode 100644 index 0000000..653e2f7 --- /dev/null +++ b/packages/schemas/fixtures/openabtest/reconciliation-pending.json @@ -0,0 +1,27 @@ +{ + "openabtest": "0.1-draft", + "producerId": "chovy-server", + "manifestId": "chovy-signup-discount-v1", + "manifestRevision": 1, + "participantId": "participant-opaque-7d12", + "at": "2026-09-15T12:00:00Z", + "id": "event-reconciliation-pending-42", + "kind": "reconciliation", + "payload": { + "assignmentId": "assignment-42", + "variantId": "signup-10", + "conversionId": "purchase-42", + "accountingRevision": 1, + "currency": "USD", + "state": "pending", + "revenueMinor": 36000, + "refundsMinor": 0, + "actualCostMinor": null, + "feesMinor": null, + "affiliateMinor": null, + "retainedProfitMinor": null, + "minimumRetainedProfitMinor": 5000, + "payoutStatus": "withheld", + "proofRefs": [] + } +} diff --git a/packages/schemas/fixtures/openabtest/reconciliation.json b/packages/schemas/fixtures/openabtest/reconciliation.json new file mode 100644 index 0000000..244df29 --- /dev/null +++ b/packages/schemas/fixtures/openabtest/reconciliation.json @@ -0,0 +1,30 @@ +{ + "openabtest": "0.1-draft", + "producerId": "chovy-server", + "manifestId": "chovy-signup-discount-v1", + "manifestRevision": 1, + "participantId": "participant-opaque-7d12", + "at": "2026-09-15T12:00:00Z", + "id": "event-reconciliation-42", + "kind": "reconciliation", + "payload": { + "assignmentId": "assignment-42", + "variantId": "signup-10", + "conversionId": "purchase-42", + "accountingRevision": 2, + "currency": "USD", + "state": "reconciled", + "revenueMinor": 36000, + "refundsMinor": 0, + "actualCostMinor": 10000, + "feesMinor": 1000, + "affiliateMinor": 20000, + "retainedProfitMinor": 5000, + "minimumRetainedProfitMinor": 5000, + "payoutStatus": "eligible", + "proofRefs": [ + "cost-proof-42", + "fees-proof-42" + ] + } +} diff --git a/packages/schemas/package.json b/packages/schemas/package.json index f8d5b75..be4c0f2 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -1,7 +1,7 @@ { "name": "@logicsrc/schemas", - "version": "0.2.0", - "description": "LogicSRC JSON schemas for tasks, agents, runs, events, plugins, the AgentAd ad standard, the OpenOntology knowledge contracts, the OpenContext context plane, the OpenCreds credential vault, OpenRental membership and rental offers, and the OpenWall messaging proposal.", + "version": "0.3.0", + "description": "LogicSRC JSON schemas for tasks, agents, runs, events, plugins, the AgentAd ad standard, the OpenOntology knowledge contracts, the OpenContext context plane, the OpenCreds credential vault, OpenRental membership and rental offers, and the OpenWall messaging proposal. Includes the OpenABTest draft experiment and event contracts.", "license": "MIT", "type": "module", "repository": { @@ -26,7 +26,8 @@ "openontology", "password-manager", "standards", - "vault" + "vault", + "openabtest" ], "publishConfig": { "access": "public" @@ -92,7 +93,9 @@ "./repo": "./schemas/logicsrc-repo.schema.json", "./run": "./schemas/logicsrc-run.schema.json", "./social-post": "./schemas/logicsrc-social-post.schema.json", - "./task": "./schemas/logicsrc-task.schema.json" + "./task": "./schemas/logicsrc-task.schema.json", + "./openabtest-manifest": "./schemas/logicsrc-openabtest-manifest.schema.json", + "./openabtest-event": "./schemas/logicsrc-openabtest-event.schema.json" }, "files": [ "schemas", diff --git a/packages/schemas/schemas/logicsrc-openabtest-event.schema.json b/packages/schemas/schemas/logicsrc-openabtest-event.schema.json new file mode 100644 index 0000000..05ff585 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-openabtest-event.schema.json @@ -0,0 +1,460 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://logicsrc.com/schemas/logicsrc-openabtest-event.schema.json", + "title": "OpenABTest authorized event (0.1 draft)", + "type": "object", + "additionalProperties": false, + "required": [ + "openabtest", + "id", + "producerId", + "manifestId", + "manifestRevision", + "participantId", + "at", + "kind", + "payload" + ], + "properties": { + "openabtest": { + "const": "0.1-draft" + }, + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "producerId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "manifestId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "manifestRevision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "participantId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "kind": { + "enum": [ + "eligibility", + "assignment", + "exposure", + "conversion", + "adjustment", + "reconciliation" + ] + }, + "payload": { + "type": "object" + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "eligibility" + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "cohortId", + "eligible" + ], + "properties": { + "cohortId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "eligible": { + "const": true + } + } + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "assignment" + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "assignmentId", + "variantId", + "cohortId" + ], + "properties": { + "assignmentId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "variantId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "cohortId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + } + } + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "exposure" + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "assignmentId", + "variantId", + "offerId", + "surface" + ], + "properties": { + "assignmentId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "variantId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "offerId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "surface": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + } + } + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "conversion" + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "assignmentId", + "variantId", + "conversionId", + "price" + ], + "properties": { + "assignmentId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "variantId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "conversionId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "price": { + "type": "object", + "additionalProperties": false, + "required": [ + "offerId", + "acceptedAt", + "currency", + "listUnitPriceMinor", + "chargedUnitPriceMinor", + "discountBps", + "quantityMilliUnits", + "rounding", + "totalMinor" + ], + "properties": { + "offerId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "acceptedAt": { + "type": "string", + "format": "date-time" + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "listUnitPriceMinor": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "chargedUnitPriceMinor": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "discountBps": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "quantityMilliUnits": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "rounding": { + "const": "half-up" + }, + "totalMinor": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + } + } + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "adjustment" + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "assignmentId", + "variantId", + "conversionId", + "adjustmentId", + "currency", + "amountMinor", + "reason", + "proofRefs" + ], + "properties": { + "assignmentId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "variantId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "conversionId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "adjustmentId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "amountMinor": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "reason": { + "enum": [ + "refund", + "chargeback", + "correction" + ] + }, + "proofRefs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "uniqueItems": true, + "maxItems": 100, + "minItems": 1 + } + } + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "reconciliation" + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "assignmentId", + "variantId", + "conversionId", + "accountingRevision", + "currency", + "state", + "revenueMinor", + "refundsMinor", + "actualCostMinor", + "feesMinor", + "affiliateMinor", + "retainedProfitMinor", + "minimumRetainedProfitMinor", + "payoutStatus", + "proofRefs" + ], + "properties": { + "assignmentId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "variantId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "conversionId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "accountingRevision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "state": { + "enum": [ + "pending", + "reconciled" + ] + }, + "revenueMinor": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "refundsMinor": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "actualCostMinor": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 9007199254740991 + }, + "feesMinor": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 9007199254740991 + }, + "affiliateMinor": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 9007199254740991 + }, + "retainedProfitMinor": { + "type": [ + "integer", + "null" + ], + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "minimumRetainedProfitMinor": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "payoutStatus": { + "enum": [ + "withheld", + "eligible" + ] + }, + "proofRefs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "uniqueItems": true, + "maxItems": 100 + } + } + } + } + } + } + ] +} diff --git a/packages/schemas/schemas/logicsrc-openabtest-manifest.schema.json b/packages/schemas/schemas/logicsrc-openabtest-manifest.schema.json new file mode 100644 index 0000000..6bb3c48 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-openabtest-manifest.schema.json @@ -0,0 +1,291 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://logicsrc.com/schemas/logicsrc-openabtest-manifest.schema.json", + "title": "OpenABTest experiment manifest (0.1 draft)", + "type": "object", + "additionalProperties": false, + "required": [ + "openabtest", + "id", + "revision", + "name", + "state", + "cohort", + "assignment", + "variants", + "window", + "metrics", + "guardrails" + ], + "properties": { + "openabtest": { + "const": "0.1-draft" + }, + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "state": { + "enum": [ + "draft", + "running", + "paused", + "closed" + ] + }, + "cohort": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "eligibilityRule", + "purchaseScope" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "eligibilityRule": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "purchaseScope": { + "enum": [ + "all-referred-purchases", + "all-eligible-purchases", + "first-eligible-purchase", + "custom" + ] + } + } + }, + "assignment": { + "type": "object", + "additionalProperties": false, + "required": [ + "unit", + "authority", + "keyVersion", + "algorithm", + "persistence" + ], + "properties": { + "unit": { + "enum": [ + "user", + "account", + "session", + "device", + "custom" + ] + }, + "authority": { + "const": "server" + }, + "keyVersion": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "algorithm": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "persistence": { + "const": "sticky" + } + } + }, + "variants": { + "type": "array", + "minItems": 2, + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "weight", + "parameters" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "weight": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "parameters": { + "type": "object", + "minProperties": 1, + "maxProperties": 50, + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + } + } + }, + "window": { + "type": "object", + "additionalProperties": false, + "required": [ + "startsAt", + "endsAt" + ], + "properties": { + "startsAt": { + "type": "string", + "format": "date-time" + }, + "endsAt": { + "type": "string", + "format": "date-time" + } + } + }, + "metrics": { + "type": "object", + "additionalProperties": false, + "required": [ + "primary", + "denominator", + "conversion", + "profit", + "minimumEligiblePerVariant", + "minimumObservationSeconds", + "winnerPolicy" + ], + "properties": { + "primary": { + "enum": [ + "conversion-rate", + "retained-profit-per-eligible-visitor" + ] + }, + "denominator": { + "const": "unique-eligible-participants" + }, + "conversion": { + "const": "unique-participants-with-confirmed-purchase" + }, + "profit": { + "const": "reconciled-net-retained-profit" + }, + "minimumEligiblePerVariant": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "minimumObservationSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "winnerPolicy": { + "const": "manual-review" + } + } + }, + "guardrails": { + "type": "object", + "additionalProperties": false, + "required": [ + "preserveAcceptedOffers", + "preserveAccruedCommissions", + "requireReconciledCostsForPayout", + "onBreach" + ], + "properties": { + "preserveAcceptedOffers": { + "const": true + }, + "preserveAccruedCommissions": { + "const": true + }, + "requireReconciledCostsForPayout": { + "const": true + }, + "onBreach": { + "const": "pause-new-assignments" + } + } + }, + "economics": { + "type": "object", + "additionalProperties": false, + "required": [ + "currency", + "minorUnitExponent", + "unit", + "listUnitPriceMinor", + "modeledUnitCostMinor", + "minimumRetainedUnitProfitMinor" + ], + "properties": { + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "minorUnitExponent": { + "type": "integer", + "minimum": 0, + "maximum": 6 + }, + "unit": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "listUnitPriceMinor": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "modeledUnitCostMinor": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 9007199254740991 + }, + "minimumRetainedUnitProfitMinor": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + } + } +} diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 32ebe02..28006c0 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -32,3 +32,13 @@ Use `@logicsrc/validators` 0.2.0 or newer for OpenRental validation. See the bindings, exact decimal rental rates and CoinPay settlement metadata. MIT © Profullstack, Inc. + +## OpenABTest draft + +Version 0.3.0 adds `OpenABTestManifest`, `OpenABTestEvent`, +`createOpenABTestManifest` and `createOpenABTestEvent`. The constructors set +`openabtest: "0.1-draft"`; validate their result with `@logicsrc/validators` +using `openabtest-manifest` or `openabtest-event` before storing it. They do not +assign participants, authenticate events or initiate payments. See the +[OpenABTest specification](https://logicsrc.com/docs/openabtest) and its complete +Chovy fixture for every referred purchase. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index ea23ec1..d36bcdf 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@logicsrc/sdk", - "version": "0.2.0", + "version": "0.3.0", "description": "LogicSRC SDK contract types and client interface.", "type": "module", "main": "./dist/index.js", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 9c46fcc..174e77d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -94,3 +94,6 @@ export function createAgentSwarmSession(input: { openspec_only: input.openspecOnly ?? false }; } + +export { createOpenABTestManifest, createOpenABTestEvent } from "./openabtest.js"; +export type { OpenABTestManifest, OpenABTestEvent, OpenABTestPrice, OpenABTestReconciliation } from "./openabtest.js"; diff --git a/packages/sdk/src/openabtest.ts b/packages/sdk/src/openabtest.ts new file mode 100644 index 0000000..4c51bcc --- /dev/null +++ b/packages/sdk/src/openabtest.ts @@ -0,0 +1,81 @@ +/** Draft private contracts; constructors do not assign users, authorize events or pay money. */ +export interface OpenABTestManifest { + openabtest: "0.1-draft"; + id: string; + revision: number; + name: string; + state: "draft" | "running" | "paused" | "closed"; + cohort: { id: string; eligibilityRule: string; purchaseScope: "all-referred-purchases" | "all-eligible-purchases" | "first-eligible-purchase" | "custom" }; + assignment: { unit: "user" | "account" | "session" | "device" | "custom"; authority: "server"; keyVersion: string; algorithm: string; persistence: "sticky" }; + variants: Array<{ id: string; weight: number; parameters: Record }>; + window: { startsAt: string; endsAt: string }; + metrics: { + primary: "conversion-rate" | "retained-profit-per-eligible-visitor"; + denominator: "unique-eligible-participants"; + conversion: "unique-participants-with-confirmed-purchase"; + profit: "reconciled-net-retained-profit"; + minimumEligiblePerVariant: number; + minimumObservationSeconds: number; + winnerPolicy: "manual-review"; + }; + guardrails: { preserveAcceptedOffers: true; preserveAccruedCommissions: true; requireReconciledCostsForPayout: true; onBreach: "pause-new-assignments" }; + economics?: { currency: string; minorUnitExponent: number; unit: string; listUnitPriceMinor: number; modeledUnitCostMinor: number | null; minimumRetainedUnitProfitMinor: number }; +} + +export interface OpenABTestPrice { + offerId: string; + acceptedAt: string; + currency: string; + listUnitPriceMinor: number; + chargedUnitPriceMinor: number; + discountBps: number; + quantityMilliUnits: number; + rounding: "half-up"; + totalMinor: number; +} + +type Assigned = { assignmentId: string; variantId: string }; +type Converted = Assigned & { conversionId: string }; +export interface OpenABTestReconciliation extends Converted { + accountingRevision: number; + currency: string; + state: "pending" | "reconciled"; + revenueMinor: number; + refundsMinor: number; + actualCostMinor: number | null; + feesMinor: number | null; + affiliateMinor: number | null; + retainedProfitMinor: number | null; + minimumRetainedProfitMinor: number; + payoutStatus: "withheld" | "eligible"; + proofRefs: string[]; +} + +type EventDetail = + | { kind: "eligibility"; payload: { cohortId: string; eligible: true } } + | { kind: "assignment"; payload: Assigned & { cohortId: string } } + | { kind: "exposure"; payload: Assigned & { offerId: string; surface: string } } + | { kind: "conversion"; payload: Converted & { price: OpenABTestPrice } } + | { kind: "adjustment"; payload: Converted & { adjustmentId: string; currency: string; amountMinor: number; reason: "refund" | "chargeback" | "correction"; proofRefs: string[] } } + | { kind: "reconciliation"; payload: OpenABTestReconciliation }; + +export type OpenABTestEvent = { + openabtest: "0.1-draft"; + id: string; + producerId: string; + manifestId: string; + manifestRevision: number; + participantId: string; + at: string; +} & EventDetail; + +/** Validate with @logicsrc/validators before storing or using the document. */ +export function createOpenABTestManifest(input: Omit): OpenABTestManifest { + return { ...input, openabtest: "0.1-draft" }; +} + +type EventInput = Omit & EventDetail; +/** The caller supplies authenticated context and durable IDs; this helper creates neither. */ +export function createOpenABTestEvent(input: EventInput): OpenABTestEvent { + return { ...input, openabtest: "0.1-draft" }; +} diff --git a/packages/validators/package.json b/packages/validators/package.json index ecbbf6c..e94cea7 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -1,6 +1,6 @@ { "name": "@logicsrc/validators", - "version": "0.2.0", + "version": "0.3.0", "description": "LogicSRC schema validation helpers.", "type": "module", "main": "./dist/index.js", @@ -11,10 +11,10 @@ "scripts": { "build": "tsc -p tsconfig.json", "test": "vitest run src", - "validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml && node dist/cli.js repo ../schemas/fixtures/repo.yaml && node dist/cli.js pull-request ../schemas/fixtures/pull-request.yaml && node dist/cli.js openontology-manifest ../schemas/fixtures/openontology/valid/manifest.json && node dist/cli.js openontology-claim ../schemas/fixtures/openontology/valid/claim-relationship.json && node dist/cli.js openontology-changeset ../schemas/fixtures/openontology/valid/changeset.json && node dist/cli.js opencontext-manifest ../schemas/fixtures/opencontext/valid/manifest.json && node dist/cli.js opencontext-object ../schemas/fixtures/opencontext/valid/object-policy.json && node dist/cli.js opencontext-bundle ../schemas/fixtures/opencontext/valid/bundle.json && node dist/cli.js opencontext-decision ../schemas/fixtures/opencontext/valid/decision.json && node dist/cli.js opencontext-role ../schemas/fixtures/opencontext/valid/role.json && node dist/cli.js opencontext-provenance ../schemas/fixtures/opencontext/valid/provenance.json && node dist/cli.js opencontext-diagnostic ../schemas/fixtures/opencontext/valid/diagnostic.json && node dist/cli.js opencontext-audit-event ../schemas/fixtures/opencontext/valid/audit-event.json && node dist/cli.js openrental ../schemas/fixtures/openrental/mixed.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/broadcast.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/direct.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/announcement.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-accepted.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-retrying.json" + "validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml && node dist/cli.js repo ../schemas/fixtures/repo.yaml && node dist/cli.js pull-request ../schemas/fixtures/pull-request.yaml && node dist/cli.js openontology-manifest ../schemas/fixtures/openontology/valid/manifest.json && node dist/cli.js openontology-claim ../schemas/fixtures/openontology/valid/claim-relationship.json && node dist/cli.js openontology-changeset ../schemas/fixtures/openontology/valid/changeset.json && node dist/cli.js opencontext-manifest ../schemas/fixtures/opencontext/valid/manifest.json && node dist/cli.js opencontext-object ../schemas/fixtures/opencontext/valid/object-policy.json && node dist/cli.js opencontext-bundle ../schemas/fixtures/opencontext/valid/bundle.json && node dist/cli.js opencontext-decision ../schemas/fixtures/opencontext/valid/decision.json && node dist/cli.js opencontext-role ../schemas/fixtures/opencontext/valid/role.json && node dist/cli.js opencontext-provenance ../schemas/fixtures/opencontext/valid/provenance.json && node dist/cli.js opencontext-diagnostic ../schemas/fixtures/opencontext/valid/diagnostic.json && node dist/cli.js opencontext-audit-event ../schemas/fixtures/opencontext/valid/audit-event.json && node dist/cli.js openrental ../schemas/fixtures/openrental/mixed.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/broadcast.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/direct.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/announcement.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-accepted.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-retrying.json && node dist/cli.js openabtest-manifest ../schemas/fixtures/openabtest/chovy-manifest.json && node dist/cli.js openabtest-event ../schemas/fixtures/openabtest/reconciliation.json" }, "dependencies": { - "@logicsrc/schemas": "^0.2.0", + "@logicsrc/schemas": "^0.3.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "yaml": "^2.8.1" diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index 7332a87..d61ebc7 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -3,6 +3,7 @@ import * as addFormatsModule from "ajv-formats"; import type { ErrorObject } from "ajv"; import { parse } from "yaml"; import { isSchemaKind, schemas, type SchemaKind } from "./schemas.js"; +import { validateOpenABTest } from "./openabtest.js"; import { validateOpenRentalReferences } from "./openrental.js"; type CompiledSchema = { (data: unknown): boolean; errors?: ErrorObject[] | null }; @@ -66,6 +67,10 @@ export function validate(kind: SchemaKind, data: unknown): ValidationResult { const errors = validateOpenRentalReferences(data); if (errors.length) return { ok: false, kind, errors }; } + if (kind === "openabtest-manifest" || kind === "openabtest-event") { + const errors = validateOpenABTest(kind, data); + if (errors.length) return { ok: false, kind, errors }; + } return { ok: true, kind, data }; } diff --git a/packages/validators/src/openabtest.test.ts b/packages/validators/src/openabtest.test.ts new file mode 100644 index 0000000..cb81d23 --- /dev/null +++ b/packages/validators/src/openabtest.test.ts @@ -0,0 +1,103 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { validate } from "./index.js"; + +function fixture(name: string) { + return JSON.parse(readFileSync(new URL(`../../schemas/fixtures/openabtest/${name}.json`, import.meta.url), "utf8")); +} + +describe("OpenABTest draft contracts", () => { + it("accepts the every-referred-purchase experiment", () => { + expect(validate("openabtest-manifest", fixture("chovy-manifest")).ok).toBe(true); + }); + it.each(["eligibility", "assignment", "exposure", "conversion", "adjustment", "reconciliation-pending", "reconciliation"])("accepts %s independently", (name) => { + expect(validate("openabtest-event", fixture(name)).ok).toBe(true); + }); + it("rejects duplicate variant identities even when weights differ", () => { + const manifest = fixture("chovy-manifest"); + manifest.variants[1].id = manifest.variants[0].id; + manifest.variants[1].weight = 2; + expect(validate("openabtest-manifest", manifest).ok).toBe(false); + }); + it.each([0, -1, 0.5, Number.MAX_SAFE_INTEGER + 1])("rejects invalid weight %s", (weight) => { + const manifest = fixture("chovy-manifest"); + manifest.variants[0].weight = weight; + expect(validate("openabtest-manifest", manifest).ok).toBe(false); + }); + it("rejects unsafe sums and backwards windows", () => { + const manifest = fixture("chovy-manifest"); + manifest.variants[0].weight = Number.MAX_SAFE_INTEGER; + expect(validate("openabtest-manifest", manifest).ok).toBe(false); + manifest.variants[0].weight = 1; + manifest.window.endsAt = manifest.window.startsAt; + expect(validate("openabtest-manifest", manifest).ok).toBe(false); + }); + it("rejects client pricing authority and malformed discounts", () => { + const manifest = fixture("chovy-manifest"); + manifest.assignment.authority = "client"; + expect(validate("openabtest-manifest", manifest).ok).toBe(false); + manifest.assignment.authority = "server"; + manifest.variants[0].parameters.discountBps = "500"; + expect(validate("openabtest-manifest", manifest).ok).toBe(false); + }); + it.each(["assignment", "exposure", "conversion"])("requires assignment attribution on %s", (name) => { + const event = fixture(name); + delete event.payload.assignmentId; + expect(validate("openabtest-event", event).ok).toBe(false); + }); + it("rejects unrecognized event payloads and identifiable participant values", () => { + const event = fixture("exposure"); + event.payload.email = "person@example.com"; + expect(validate("openabtest-event", event).ok).toBe(false); + delete event.payload.email; + event.participantId = "person@example.com"; + expect(validate("openabtest-event", event).ok).toBe(false); + }); + it("checks the accepted discount and half-up usage arithmetic", () => { + const event = fixture("conversion"); + event.payload.price.chargedUnitPriceMinor = 38000; + expect(validate("openabtest-event", event).ok).toBe(false); + Object.assign(event.payload.price, { listUnitPriceMinor: 101, chargedUnitPriceMinor: 91, quantityMilliUnits: 500, totalMinor: 46 }); + expect(validate("openabtest-event", event).ok).toBe(true); + event.payload.price.totalMinor = 45; + expect(validate("openabtest-event", event).ok).toBe(false); + }); + it("rejects a conversion dated before the accepted offer", () => { + const event = fixture("conversion"); + event.at = "2026-09-14T00:00:00Z"; + expect(validate("openabtest-event", event).ok).toBe(false); + }); + it("requires a revenue reduction and evidence for refunds", () => { + const event = fixture("adjustment"); + event.payload.amountMinor = 3600; + expect(validate("openabtest-event", event).ok).toBe(false); + event.payload.amountMinor = -3600; + event.payload.proofRefs = []; + expect(validate("openabtest-event", event).ok).toBe(false); + }); + it("withholds payouts when actual costs or fees are missing", () => { + const event = fixture("reconciliation"); + event.payload.actualCostMinor = null; + expect(validate("openabtest-event", event).ok).toBe(false); + event.payload.state = "pending"; + expect(validate("openabtest-event", event).ok).toBe(false); + Object.assign(event.payload, { affiliateMinor: null, retainedProfitMinor: null, payoutStatus: "withheld" }); + expect(validate("openabtest-event", event).ok).toBe(true); + }); + it("rejects inconsistent accounting and unsupported profit claims", () => { + const event = fixture("reconciliation"); + event.payload.affiliateMinor = 21000; + expect(validate("openabtest-event", event).ok).toBe(false); + event.payload.retainedProfitMinor = 4000; + expect(validate("openabtest-event", event).ok).toBe(false); + Object.assign(event.payload, { affiliateMinor: 0, actualCostMinor: 36000, retainedProfitMinor: -1000, payoutStatus: "withheld" }); + expect(validate("openabtest-event", event).ok).toBe(true); + }); + it("requires reconciliation evidence without bearer URLs", () => { + const event = fixture("reconciliation"); + event.payload.proofRefs = []; + expect(validate("openabtest-event", event).ok).toBe(false); + event.payload.proofRefs = ["https://example.com/receipt?token=secret"]; + expect(validate("openabtest-event", event).ok).toBe(false); + }); +}); diff --git a/packages/validators/src/openabtest.ts b/packages/validators/src/openabtest.ts new file mode 100644 index 0000000..194acbe --- /dev/null +++ b/packages/validators/src/openabtest.ts @@ -0,0 +1,68 @@ +import type { ErrorObject } from "ajv"; + +type AccountingEvent = + | { kind: "conversion"; at: string; payload: { price: { listUnitPriceMinor: number; discountBps: number; quantityMilliUnits: number; chargedUnitPriceMinor: number; totalMinor: number; acceptedAt: string } } } + | { kind: "adjustment"; payload: { reason: string; amountMinor: number } } + | { kind: "reconciliation"; payload: { revenueMinor: number; refundsMinor: number; actualCostMinor: number | null; feesMinor: number | null; affiliateMinor: number | null; retainedProfitMinor: number | null; minimumRetainedProfitMinor: number; state: string; payoutStatus: string; proofRefs: string[] } }; + +// These checks run only after structural validation. Cross-event authorization, +// assignment lookup and idempotency still require the issuer's private ledger. +export function validateOpenABTest(kind: string, data: unknown): ErrorObject[] { + const errors: ErrorObject[] = []; + function report(path: string, message: string) { + errors.push({ keyword: "openabtest", instancePath: path, schemaPath: "#/openabtest-semantics", params: {}, message }); + } + if (kind === "openabtest-manifest") { + const manifest = data as { + variants: Array<{ id: string; weight: number; parameters: Record }>; + window: { startsAt: string; endsAt: string }; + }; + const seen = new Set(); + let total = 0n; + manifest.variants.forEach((variant, index) => { + if (seen.has(variant.id)) report(`/variants/${index}/id`, "must be unique within the manifest"); + seen.add(variant.id); + total += BigInt(variant.weight); + const discount = variant.parameters.discountBps; + if (discount !== undefined && (typeof discount !== "number" || !Number.isInteger(discount) || discount < 0 || discount > 10000)) { + report(`/variants/${index}/parameters/discountBps`, "must be an integer from 0 to 10000"); + } + }); + if (total > BigInt(Number.MAX_SAFE_INTEGER)) report("/variants", "total weight must be a safe integer"); + if (Date.parse(manifest.window.endsAt) <= Date.parse(manifest.window.startsAt)) { + report("/window/endsAt", "must be later than startsAt"); + } + } else { + const event = data as AccountingEvent; + if (event.kind === "conversion") { + const price = event.payload.price; + const unit = (BigInt(price.listUnitPriceMinor) * BigInt(10000 - price.discountBps) + 5000n) / 10000n; + const total = (unit * BigInt(price.quantityMilliUnits) + 500n) / 1000n; + if (BigInt(price.chargedUnitPriceMinor) !== unit) report("/payload/price/chargedUnitPriceMinor", "must equal the discounted list unit price rounded half up"); + if (BigInt(price.totalMinor) !== total) report("/payload/price/totalMinor", "must equal the charged unit price times quantity rounded half up"); + if (Date.parse(price.acceptedAt) > Date.parse(event.at)) report("/payload/price/acceptedAt", "cannot be after the conversion event"); + } + if (event.kind === "adjustment" && (event.payload.reason === "refund" || event.payload.reason === "chargeback") && event.payload.amountMinor >= 0) { + report("/payload/amountMinor", "refunds and chargebacks must reduce recognized revenue"); + } + if (event.kind === "reconciliation") { + const p = event.payload; + if (p.refundsMinor > p.revenueMinor) report("/payload/refundsMinor", "cannot exceed revenue; other expenses belong in actual costs or fees"); + if (p.state === "pending") { + if (p.payoutStatus !== "withheld" || p.affiliateMinor !== null || p.retainedProfitMinor !== null) { + report("/payload", "pending reconciliation must withhold payout and leave affiliate and retained profit unknown"); + } + } else if (p.actualCostMinor === null || p.feesMinor === null || p.affiliateMinor === null || p.retainedProfitMinor === null) { + report("/payload", "reconciled accounting requires actual costs, fees, affiliate and retained profit"); + } else { + const retained = BigInt(p.revenueMinor) - BigInt(p.refundsMinor) - BigInt(p.actualCostMinor) - BigInt(p.feesMinor) - BigInt(p.affiliateMinor); + if (retained !== BigInt(p.retainedProfitMinor)) report("/payload/retainedProfitMinor", "must reconcile exactly to net revenue less actual costs, fees and affiliate allocation"); + if (p.proofRefs.length === 0) report("/payload/proofRefs", "reconciled accounting requires private evidence references"); + if (p.retainedProfitMinor < p.minimumRetainedProfitMinor && (p.affiliateMinor !== 0 || p.payoutStatus !== "withheld")) { + report("/payload", "a profit-floor shortfall must withhold new payout and allocate zero new affiliate amount"); + } + } + } + } + return errors; +} diff --git a/packages/validators/src/schemas.ts b/packages/validators/src/schemas.ts index 6c99120..c230147 100644 --- a/packages/validators/src/schemas.ts +++ b/packages/validators/src/schemas.ts @@ -1,3 +1,5 @@ +import openabtestManifestSchema from "@logicsrc/schemas/openabtest-manifest" with { type: "json" }; +import openabtestEventSchema from "@logicsrc/schemas/openabtest-event" with { type: "json" }; /** * Every LogicSRC JSON Schema, keyed by kind. * @@ -73,6 +75,8 @@ import openwallMessageSchema from "@logicsrc/schemas/openwall-message" with { ty import openwallReceiptSchema from "@logicsrc/schemas/openwall-receipt" with { type: "json" }; export const schemas = { + "openabtest-manifest": openabtestManifestSchema, + "openabtest-event": openabtestEventSchema, "openwall-message": openwallMessageSchema, "openwall-receipt": openwallReceiptSchema, agent: agentSchema,