Skip to content

Sprint 5: stats endpoint + data integrity — transactional ingestion, impression beacon, registered event types - #16

Merged
hynding merged 14 commits into
mainfrom
sprint-5-stats-integrity
Jul 8, 2026
Merged

Sprint 5: stats endpoint + data integrity — transactional ingestion, impression beacon, registered event types#16
hynding merged 14 commits into
mainfrom
sprint-5-stats-integrity

Conversation

@hynding

@hynding hynding commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the Sprint 5 plan (docs/superpowers/plans/2026-07-07-sprint-5-stats-integrity.md) — the first v1.x sprint, themed "trustworthy numbers": fix the write path, then build the stats read path on top.

  • Transactional ingestion (closes Make event ingestion transactional (dedup records receipt, not completion) #2): new IngestionStore wraps event dedup-insert → progress increments → unlock inserts → usage in one db.transaction; a mid-chain failure rolls back the event row too, so dedup now records completion and SDK retries re-process instead of silently skipping
  • Race-free progress (closes Fix lost-update race on achievement progress counters #3): increments happen in SQL (LEAST(current + delta, target) … RETURNING current); evaluateEvent returns deltas and the post-increment value decides unlocks — concurrent events can no longer lose updates (Testcontainers race + rollback tests)
  • Accurate impressions (closes Offer impression accuracy: dismissed offers, retry duplicates, priority tie determinism #6): the placements GET no longer records impressions; the Placement widget fires an idempotent POST /v1/offers/:id/impression beacon only after the render decision passes the dismissal check (partial unique index dedups retries); resolveOffer gains a deterministic equal-priority tie-break
  • Validated config plane (closes Validate config-plane responses in adapter-strapi + test verifyKey cache paths #4): all six adapter-strapi fetches are zod-validated (parse failure = fetch error → stale-on-error); verifyKey fails closed on bad enums with a warn log; the three missing cache-path tests added
  • Registered event types: opt-in per project; unknown types get 400 unregistered_event_type with a Levenshtein did-you-mean suggestion
  • GET /v1/stats (secret-key-only): totals + per-achievement/offer (CTR)/timed-event participation, optional from/to range; SDK gains a server-side-only secretKey option + getStats(); demo gets a server-rendered /stats page (sk never reaches the browser)

Behavior note for existing clients

One intentional wire-content change on POST /v1/events: the progress array now includes achievements already at target (clamped current = target) where it previously omitted them. Schema-compatible; spec-intended (evaluation no longer skips — SQL clamping makes at-target increments no-ops).

Test plan

  • 72 API tests (stats, beacon, typo rejection among them); adapter-db Testcontainers suite covers the concurrency race, transaction rollback, beacon idempotency, and stats aggregation (range boundaries, cross-tenant isolation)
  • All 3 Playwright specs passed live, including new assertions: exactly one impression POST on render, zero after dismiss+reload, and the /stats page correlating the actual unlocked achievement
  • Live curls: sk → real aggregates, pk → 403; full pnpm turbo run typecheck build test green (27/27, re-verified post fix-wave)

Review notes

Ten tasks through the full implement→review gate cycle plus a final whole-branch review — verdict ready to merge with fixes, fix wave applied and re-approved (e68b73f: verify-key validation warn + fail-closed comment, delta rounding rationale). Follow-ups filed: #13 (offer-id validation on impression/click — stats pollution hardening), #14 (test-hardening bundle), #15 (stats polish bundle). Migration 0003 is safe on existing data (partial unique index predicate excludes all pre-existing rows).

🤖 Generated with Claude Code

hynding and others added 14 commits July 7, 2026 21:44
Scope decided with Steve: GET /v1/stats (sk-only, totals + per-entity),
registered event types with typo rejection, transactional ingestion (#2),
SQL-side progress increments (#3), impression beacon (#6), config-plane
zod validation (#4), SDK secretKey + getStats(), demo stats page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ten tasks in dependency order: contracts → core (delta evaluate, suggester,
new ports) → adapter-db (transactional ingestion, stats store, migration
0003) → cms (registered event types) → adapter-strapi (zod validation, #4)
→ api (ingestion rewire #2/#3, impression beacon #6, stats route) → sdk +
widgets → demo stats page + e2e + docs DoD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… schemas

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion and stats ports

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ore, impression dedup migration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ered event types (closes #4 scope)

Replaces every unchecked `as` cast / blind String()/Number()/Boolean() coercion on
StrapiConfigPlane's fetch responses with module-local zod schemas (packages/adapter-strapi/src/schemas.ts),
so a malformed CMS response is treated exactly like a failed fetch and falls through
to the existing TTL stale-on-error path. verifyKey validates environment/keyType as
strict enums and fails closed to null auth (never a corrupt AuthContext) on any parse
failure. Adds getRegisteredEventTypes(projectId), implementing the ConfigStore port
widened in an earlier task, against the config-plane event-types endpoint with the
same TTL cache + stale-on-error machinery as the other methods. Drops the unnecessary
content-type header on GET requests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on (closes #2, #3 scope)

Rewires POST /v1/events onto the transactional IngestionStore (dedup,
clamped progress, exactly-once unlocks in one DB transaction) and rejects
unregistered event types with a typo suggestion, failing open if the
config plane is unreachable. Also fixes a placements.ts compile break
(unrelated 5-arg OfferMetricsStore widening from an earlier task) so the
workspace typechecks green again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ger records (closes #6 scope)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ssion beacon

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…de try

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds apps/demo/app/stats/page.tsx, a server component that calls the
secret-key-only GET /v1/stats via @promocean/sdk and renders totals plus
achievement/offer/timed-event tables. Seeds a demo sk key alongside the
existing pk key (apps/cms/src/index.ts), wires PROMOCEAN_SECRET_KEY through
.env.example and CI. Extends offer-loop.spec.ts to assert the impression
beacon fires exactly once on render and never for a dismissed offer, and
achievement-loop.spec.ts to correlate a real unlock with its /stats row.
Documents getStats/secretKey and recordImpression in the SDK README,
impression semantics in the widgets README, and the new API surface rows +
registered-event-types opt-in in the root README.

closes #2, #3, #6; closes the #4 scope (adapter validation + cache tests).
Registered event types and stats are the first two v1.x roadmap items
delivered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ationale

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hynding
hynding merged commit a68c748 into main Jul 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant