0067: Enforce MCT Snapshot at Click-Time in Player API
STATUS
Accepted
CONTEXT
User Story
As a Player API system, we need to snapshot MCT (Maximum Conversion Time) rules at click-time so that reward eligibility is evaluated against the original terms presented to the user — not the current (possibly edited) campaign configuration.
Background
MCT = maximum_completion_time_days — a per-goal attribute (sourced upstream from the campaign/goal model in the Adaction main API) that defines the eligibility window length in days. A goal completion is in-window when completion_time <= anchor + MCT * 24h, where:
-
Anchor (start of the window):
transaction.attribution_time(=completed_atof the campaign's attribution goal) when the attribution goal has completed.click.created_at(the Click record's timestamp) otherwise — i.e. campaigns without an attribution goal, or goals being evaluated before the attribution goal completes.
-
Completion time (when the player completed the goal): supplied by the caller in the goal-completion request body; defaults to
now_utcif absent. Distinct from credit-processing time, which can lag actual completion due to support cases decided in the player's favor, conversions sitting in pending state in Tune before approval, outages in MMP / Tune / Keystone / Postback Processor, or other delays. Comparing the deadline againstnow_utcat credit-receipt time would falsely expire delayed credits; comparing againstcompletion_timefixes this.
In code:
$anchor = $transaction->attributionTime ?? $click->createdAt;
$completionTime = $request->completionTime ?? $now;
Today, Player API does not store any campaign or goal configuration:
- The
Clickrecord (app/Data/Dtos/ClickData.php) only carries:transaction_id,adgem_uid,app_id,campaign_id,event_data,created_at. - The
Transactionrecord (app/Data/Dtos/TransactionData.php) carriescampaign_id,tracking_type, and aGoalCollection, but no eligibility window. - The
Goalrecord (app/Data/Dtos/GoalData.php) hasgoalId,externalGoalId,offerGoalId,completedAt,rewardAmount, etc. — nomaximum_completion_time_days. - Transaction
status(started / in-progress / completed / expired) is currently computed at read time using a global TTL constant (config('playerapi.player-offers-ttl'), defaults to 120 days, inAmerica/New_York) — not a per-goal MCT. campaign_idis treated as an opaque identifier; campaign metadata is never fetched.
This means today's "expired" status is a global heuristic, not a contractual eligibility window. If a campaign's MCT changes upstream after a player has clicked, the player is silently subject to the new rules. Snapshotting at click-time fixes this.
Spike Objective
Investigate how Player API can:
- Store MCT value at click event (transaction creation).
- Freeze the eligibility window per transaction.
- Prevent retroactive rule changes from affecting existing users.
- Handle campaign edits mid-flight.
- Maintain scalability and performance on DynamoDB.
Assumptions (decided ahead of the spike)
-
Source of MCT: pushed by the upstream caller (Adaction main API) inside the
StoreTransactionDatapayload — specifically as a field on each goal ingoals[]— when callingPOST /apps/{appId}/players/{adgemUid}/transactions. Player API does not fetch campaign config; it persists what it receives. -
Upstream contract (from the consuming project's HTTP client):
// upstream: createTransaction(CreateTransactionData $transaction)$this->getHttpClient()->post($this->playerApiUrl.$this->apiV1.'/apps/'.$transaction->appId.'/players/'.$transaction->adgemUid.'/transactions',['appId' => $transaction->appId,'adgemUid' => $transaction->adgemUid,'campaignId' => $transaction->campaignId,'trackingType' => $transaction->trackingType,'bundleId' => $transaction->bundleId,'goals' => array_map(fn ($goal) => $goal->toArray(), $transaction->goals),'offerId' => $transaction->offerId,'subSiteId' => $transaction->subSiteId,'fingerprint' => $transaction->fingerprint,]);Adding
maximum_completion_time_daysto the upstream's goal DTO surfaces it automatically through$goal->toArray()into each entry ofgoals[]. Player API extracts and freezes it during transaction creation — this is the system's earliest checkpoint after the click and serves as the click-time snapshot in the funnel. -
Granularity: MCT is per-goal (
maximum_completion_time_days). A campaign with N goals carries N MCT values. -
Persistence layer: DynamoDB single-table (
AdTracking), as already used for Click / Transaction / Reward records. The Transaction item already persists itsGoalCollection, so the snapshot lives naturally inside that aggregate.
Key Questions & Findings
Q1. Is MCT stored per campaign or per goal?
Per goal. Each goal in a campaign carries its own maximum_completion_time_days. A campaign-wide MCT is not sufficient — multi-goal campaigns can have different windows per goal (e.g. install within 1 day, complete tutorial within 7 days).
Q2. What data must be persisted at click (transaction-level)?
At minimum, for every goal attached to the transaction:
| Field | Type | Purpose |
|---|---|---|
goal_id | int | Goal identifier (links to existing GoalData.goalId) |
max_completion_time_days | int | Frozen MCT value, taken from the upstream goals[].maximum_completion_time_days |
The snapshot's "captured at" timestamp is the existing Transaction created_at — no separate snapshot_at field is needed. The deadline (anchor + MCT) is not persisted: because the anchor is dynamic (attribution time may not be known at transaction creation), eligibility is computed at check time from the frozen rule and the resolved anchor.
Q3. How do we handle timezone normalization?
- Store all timestamps in UTC ISO-8601 (
Y-m-d\TH:i:s.uZ). MCT is expressed in days, but days are an ambiguous unit across timezones — adding "N days" to a UTC timestamp is unambiguous; adding it across DST boundaries inAmerica/New_Yorkis not. - At eligibility check time, resolve the anchor (
transaction.attributionTime ?? click.created_at, both already in UTC), resolve the completion time (request.completionTime ?? now_utc), and computeanchor + max_completion_time_days * 24h. The check is a single UTC comparison — no per-request TZ math. - This diverges from the current
TransactionData::calculateStatus()which usesCarbon::now('America/New_York')->subDays(...). We should plan to migrate that path to the per-goal MCT deadline rather than the global TTL cutoff. Out of scope for this spike, but called out as a follow-up.
Q4. What happens if campaign MCT changes after click?
- Existing transactions are unaffected — they evaluate against the snapshot frozen at transaction creation.
- Newly created transactions get the new MCT value (sent by upstream in the next
POST /transactionscall). - Mid-flight transactions where a goal hasn't completed yet keep the original MCT rule. This is the correctness goal of the spike.
- Edge case: if upstream issues a second
POST /transactionsfor the sametransaction_idwith a different MCT, the snapshot must not be silently overwritten — see Open Question OQ1.
Q5. How does this affect dispute resolution?
- The snapshot becomes the evidence of "what rules were the player promised". Support tooling can read it to answer "was this conversion in-window?" without re-querying upstream historical state.
- The existing
RewardOrigin::SUPPORT_RESOLUTIONpath (app/Rules/ValidRewardOrigin.php) already allows manual reward issuance that bypasses automated eligibility — that mechanism is unchanged and remains the escape hatch for legitimate disputes, fraud handling, and emergency misconfigurations. Crucially, we never mutate a persisted snapshot to "fix" eligibility after the fact: corrections ride the existing reward-origin lever (or, for fraud, the existing withholding tooling), which preserves the snapshot as the audit-of-record for what the player was originally promised. - Recommend exposing the snapshot fields in any internal admin/support endpoint that returns transaction details: a) Playerapi - getTransaction, listTransactions. b) Service Hub - case details page for support team to review.
Q6. Scalability & performance
- Snapshot is captured as part of the existing transaction-creation write — no additional DynamoDB writes under any of the options below (snapshot lives inside the Transaction item).
- Atomicity is free. Because the snapshot lives inside the Transaction item, transaction creation is a single DynamoDB
PutItem: either the Transaction and its MCT rule persist together, or neither does. There is no two-write window where a Transaction could exist without its frozen MCT. - Eligibility checks at conversion add no extra reads under any option — the snapshot is already on the Transaction loaded from DynamoDB (the Click record is already co-loaded for the existing reward/attribution paths).
- Payload growth: ~10 bytes per goal × N goals ≪ DynamoDB's 400KB item limit.
- Eligibility check at conversion is one anchor resolution + one timestamp comparison — O(1).
- No GSI changes required for any of the proposed options below.
Considered Options
All three options share the same entry point: the upstream caller sends maximum_completion_time_days per goal in the goals[] array of POST /apps/{appId}/players/{adgemUid}/transactions. They differ in where Player API stores the frozen value.
Option A — Extend GoalData with maximumCompletionTimeDays
Add a single field to the existing per-goal record. The Goal item is already persisted with the Transaction, so simply adding the field freezes the value at transaction-creation time.
{
"transaction_id": "txn_abc",
"campaign_id": 42,
"goals": [
{
"goal_id": 1234,
"external_goal_id": null,
"created_at": "2026-05-07T14:22:01Z",
"maximum_completion_time_days": 7
}
]
}
Eligibility is computed on the fly: anchor + maximum_completion_time_days * 24h >= completion_time, where anchor = transaction.attributionTime ?? click.created_at and completion_time = request.completionTime ?? now_utc (see Background). Because both inputs are determined at check time, no derived deadline is persisted — only the rule.
Pros
- Smallest possible change — one field on one DTO.
- No new DTO, no new DynamoDB item, no new collection on
TransactionData. - The "snapshot" is implicit:
GoalDataitems are written once at transaction creation and not mutated afterwards (status fields likecompletedAtare set later butcreatedAtand the new field are not). - Matches the dynamic anchor naturally. Nothing to recompute when the attribution goal completes; the next eligibility check picks up the new anchor automatically.
Cons
- No room for snapshot provenance metadata (e.g.
source_revision,snapshot_version) without polluting the Goal record with non-goal-state fields. - Eligibility requires recomputing
anchor + dayson every check — O(1) but slightly more error-prone (DST/leap-second math) than a frozen timestamp would be. The mitigation is to centralize the math in one helper (see Eligibility evaluation below). - If we ever need to re-snapshot a goal mid-flight (correction, support override), there's no clean place to record the original vs current value.
Option B — First-class goal_snapshots collection on TransactionData
Introduce a dedicated GoalSnapshotData DTO and a goal_snapshots collection on TransactionData, populated from StoreTransactionData.goals[].maximum_completion_time_days at creation time. One entry per goal.
{
"transaction_id": "txn_abc",
"campaign_id": 42,
"goals": [ { "goal_id": 1234, "...": "..." } ],
"goal_snapshots": [
{
"goal_id": 1234,
"max_completion_time_days": 7,
"eligible_until": "2026-05-14T14:22:01Z",
"snapshot_version": 1,
"source_revision": null
}
]
}
Pros
- Eligibility check is local to the Transaction read path — no extra DynamoDB lookups.
- Strongly typed via a new
GoalSnapshotDataDTO; validated via existing Spatie Data flow. - Carries a pre-computed
eligible_untilso eligibility is a single timestamp compare; avoids per-request day arithmetic. - Carries provenance fields (
snapshot_version,source_revision) that make audits and dispute resolution clean. - Cleanly separates immutable snapshot-of-rules from mutable goal state (
completedAt,rewardAmount). - Lets us migrate
TransactionData::calculateStatus()from a global TTL to per-goaleligible_until.
Cons
- New DTO, new tests, new persistence path for the collection.
- Mild duplication of
goal_idbetweengoals[]andgoal_snapshots[]. - Dynamic anchor breaks the "frozen deadline" value proposition.
eligible_untilcannot be pre-computed at transaction creation becausetransaction.attributionTimeis generally unknown at that moment. We'd either dropeligible_until(losing the main reason to introduce a parallel collection) or update it later when the attribution goal completes (adding a second write and a partial-state failure mode).
Option C — Embed the full snapshot inside the existing GoalData
Instead of a parallel collection, add the snapshot fields directly to the existing per-goal record. The Goal item is already persisted with the Transaction, so the snapshot rides along automatically.
{
"transaction_id": "txn_abc",
"campaign_id": 42,
"goals": [
{
"goal_id": 1234,
"external_goal_id": null,
"created_at": "2026-05-07T14:22:01Z",
"completed_at": null,
"reward_amount": null,
"max_completion_time_days": 7,
"eligible_until": "2026-05-14T14:22:01Z"
}
]
}
max_completion_time_days is taken from the upstream payload; eligible_until is computed server-side in TransactionService::build() as transaction.created_at + max_completion_time_days * 86400s (so it cannot be spoofed).
Pros
- No new DTO, no new collection — just two new fields on
GoalData. - Eligibility is a single stored-timestamp comparison (
now_utc <= eligible_until); no day arithmetic at read time, so no DST/leap-second pitfalls. - All goal-related data lives in one place — easier to reason about in tests and admin UIs.
- Lighter migration than Option B (no new persisted collection, just new fields on the existing one).
Cons
- Mixes immutable snapshot fields (
max_completion_time_days,eligible_until) with mutable goal state (completed_at,reward_amount) on the same record. Discipline (or a code-level "do not mutate after write" guardrail) is needed to keep snapshot fields read-only after creation. - If multiple snapshots per goal are ever needed (e.g. an upstream-issued correction), there's no clean way to record both the original and the corrected value.
- Same dynamic-anchor problem as Option B. Pre-computing
eligible_untilfromtransaction.created_atdoesn't match the new anchor semantics (attribution_time ?? click.created_at). The pre-computed deadline would either be wrong for attribution-goal campaigns or would have to be rewritten when the attribution goal completes.
DECISION
Recommended: Option A — Extend GoalData with maximumCompletionTimeDays.
We add a single field (maximum_completion_time_days) directly onto GoalData, taken from the upstream payload and frozen at transaction creation. No derived deadline is persisted: because the eligibility anchor is dynamic (transaction.attributionTime ?? click.created_at), the deadline is computed on the fly at eligibility check time as anchor + maximum_completion_time_days * 86400s. The frozen rule rides along with the existing Goal record inside the Transaction item — no new DTO, no new collection, no new persisted aggregate.
Why Option A
Pros
- Matches the dynamic anchor semantics. The anchor is
transaction.attributionTime ?? click.created_at, andattributionTimeis generally unknown at transaction creation. Storing a pre-computedeligible_until(Options B and C) would either be wrong for attribution-goal campaigns or require a follow-up write when the attribution goal completes. Option A sidesteps the problem entirely by computing at check time. - Smallest viable change. One field on
GoalData; no new DTO, no new collection, no new persistence path. - Atomic with transaction creation. The frozen rule persists inside the existing Transaction
PutItem(see Q6) — no two-write window, no partial-state failure mode to monitor. - No re-write on attribution-goal completion. Nothing to update when
attributionTimeis set; the next eligibility check picks up the new anchor automatically. - Inputs to the check are already on the loaded aggregate.
transaction.attributionTime, the Click record'screated_at, andgoal.maxCompletionTimeDaysare co-loaded for the existing reward/attribution paths — no extra DynamoDB reads.
Cons (and how we mitigate them)
- Day arithmetic happens at every eligibility check rather than once at write time. O(1) but slightly more error-prone (DST/leap-second math) than reading a stored timestamp. Mitigation: centralize the math in a single helper (see Eligibility evaluation below) and cover DST/edge dates with unit tests.
- No room for snapshot provenance metadata (e.g.
source_revision,snapshot_version) without polluting the Goal record with non-goal-state fields. Accepted for v1; if provenance becomes load-bearing, Option B is the migration target. - No clean way to record multiple snapshots per goal (e.g. an upstream-issued correction). Accepted: corrections ride the existing
RewardOrigin::SUPPORT_RESOLUTIONlever rather than mutating the persisted snapshot, and the snapshot remains the audit-of-record (see Q5). StoreGoalDatavalidation must be strict so we never persist a partially-snapshotted Goal. Mitigation: the validation rules under DECISION (positive integer or null; 422 on anything else) are part of this decision, not optional follow-up.
Snapshot Field Reference
All three options must capture the same logical data; only the storage location and DTO shape differ. The fields below describe what must be persisted; per-option JSON examples in the Considered Options section show where.
| Field | Type | Required | Source | Notes |
|---|---|---|---|---|
goal_id | int | yes | upstream payload | Links to existing GoalData.goalId |
max_completion_time_days | int | yes (when MCT applies — see OQ4) | upstream goals[].maximum_completion_time_days | Frozen at write |
The "captured at" timestamp for the snapshot is the existing Transaction created_at — no separate snapshot_at field is persisted. Under the recommended option, no derived deadline is persisted: the deadline is computed at eligibility check time as anchor + max_completion_time_days * 86400s, where anchor = transaction.attributionTime ?? click.created_at (see Background). Options B and C would also need to store an eligible_until, but that pre-computation conflicts with the dynamic anchor — see their Cons.
Inbound payload change — transaction creation
Regardless of option, the inbound contract change is on StoreGoalData (the per-entry shape of StoreTransactionData.goals[]):
// app/Data/Requests/StoreGoalData.php
public int $goalId,
// ... existing fields ...
public ?int $maximumCompletionTimeDays, // NEW — supplied by upstream, optional (see OQ4)
StoreTransactionData itself is not changed structurally — it keeps its goals[] array.
Inbound payload change — goal completion
The goal-completion endpoint accepts an optional completion_time in the request body:
// app/Data/Requests/CompleteGoalData.php (or equivalent)
// ... existing fields ...
public ?CarbonInterface $completionTime, // NEW — optional ISO-8601 UTC; default null (treated as now_utc)
Rationale: there is often a delay between when a player actually completes a goal and when Player API receives the credit signal (support cases decided in the player's favor, conversions held in Tune pending approval, outages in MMP / Tune / Keystone / Postback Processor, etc.). Comparing the MCT deadline against now_utc at credit-receipt time would falsely expire delayed credits — comparing against caller-supplied completion_time fixes this.
completion_time is a fact (when the player completed), not a derived consequence (a deadline). It belongs on the upstream-supplied side of the trust boundary — same category as clicked_at — and Player API still computes the deadline math.
Validation rules
maximumCompletionTimeDaysis nullable. Anullvalue means the goal has no eligibility window (e.g. non-turbo goals where time-bounded conversion does not apply) — the eligibility check short-circuits to "eligible" for that goal, and no anchor resolution is performed. Per-tracking_typesemantics are still open in OQ4.- A non-null
maximumCompletionTimeDaysmust be a positive integer (> 0). Anything else (negative, zero, non-integer, string) fails Spatie Data validation and the request is rejected with 422 Unprocessable — no transaction is created. Malformed payloads have not been observed historically, but we validate defensively so a partially-snapshotted transaction is never persisted. completionTimeis nullable. Anullor absent value means the eligibility check usesnow_utcat credit-receipt time.- A non-null
completionTimemust be a valid ISO-8601 UTC timestamp and must satisfycompletionTime <= now_utc(no future timestamps). Future timestamps fail validation with 422 Unprocessable. Staleness cap (lower bound) is open — see OQ6.
Population point
TransactionService::build() (app/Libraries/Transaction/TransactionService.php) is the natural place to materialize the snapshot. It already constructs the Transaction from StoreTransactionData and stamps created_at = now_utc on the Transaction itself. For each goal with a non-null maximumCompletionTimeDays, write the value through to GoalData.maximumCompletionTimeDays. No derived deadline is computed at write time — the anchor (transaction.attributionTime ?? click.created_at) is generally not knowable yet, so the deadline is computed at check time. The DTO target depends on the chosen option:
- Option A (recommended) — write
maximumCompletionTimeDaysontoGoalDataonly. - Option B — write
max_completion_time_daysonto a newGoalSnapshotDataDTO collected inTransactionData.goalSnapshots(noeligible_until, since it can't be pre-computed). - Option C — write
max_completion_time_daysontoGoalData(noeligible_until).
Why compute the eligibility deadline in Player API (and not in upstream)?
Upstream sends the rule (max_completion_time_days); Player API computes the consequence (anchor + days) at eligibility check time. Rationale:
- Trust boundary. The deadline is what gates payouts. Anything downstream consumers use to decide "should we reward this player?" should be computed at the trust boundary, not accepted from a client — same reason we don't let upstream send
reward_amount_to_credit_now. We accept the rule; we compute the consequence. - The anchor is Player-API-owned state. Both
transaction.attributionTime(set when the attribution goal completes in Player API) andclick.created_at(set when Player API records the click) are facts Player API already owns. Letting upstream pre-compute a deadline against its own view of those values would invite drift. - Idempotency / retries. Keeping the math local to Player API keeps OQ1's "what to do on re-POST of the same
transaction_id" decision in one place: we keep the first frozen rule, period. - Simpler upstream contract. Adaction main API doesn't need to know the anchor selection rule or own day-arithmetic edge cases (DST, leap seconds). One less place to get it wrong.
Eligibility evaluation (illustrative)
The check resolves the anchor and the completion time at call time, then compares anchor + MCT * 86400s against completionTime. Sketch under Option A (recommended):
public function isGoalEligible(GoalData $goal, TransactionData $transaction, ClickData $click, DateTimeInterface $completionTime): bool
{
if ($goal->maximumCompletionTimeDays === null) {
return true; // MCT does not apply to this goal — see OQ4
}
$anchor = $transaction->attributionTime ?? $click->createdAt;
$eligibleUntil = $anchor->modify("+{$goal->maximumCompletionTimeDays} days");
return $completionTime <= $eligibleUntil;
}
The caller resolves $completionTime = $request->completionTime ?? $now before calling. Centralizing this in a single helper (e.g. GoalEligibilityService) is part of the decision — it's the one place where the anchor-selection rule lives, so the rest of the codebase calls a method rather than re-implementing the ?? and the day math. This hooks into PlayerRewardService::addReward() (app/Libraries/Player/PlayerRewardService.php) for RewardOrigin::GOAL_COMPLETION rewards; RewardOrigin::SUPPORT_RESOLUTION continues to bypass the check.
Synthetic / dummy goal completions
Player API auto-completes dependent synthetic / dummy goals when a triggering goal completes. For MCT purposes, the synthetic goal's effective completion_time is the triggering (prerequisite) goal's completed_at, not now_utc — even when the synthetic goal has its own MCT. The synthetic goal logically completed at the same moment as its prerequisite, so the eligibility check must use that moment; using now_utc would falsely expire synthetic goals that were credited late (same root cause as the caller-supplied completion_time rule, applied to a derived event).
In practice: whatever code path auto-completes the synthetic goal passes prerequisiteGoal.completedAt as the $completionTime parameter to isGoalEligible, rather than letting it default to now.
Validation Flow Diagram
Observability
This section sketches the categories of telemetry that should accompany the rollout. Concrete metric names, thresholds, alert routing, and dashboard layout are deferred to a follow-up observability doc.
- Atomicity rules out partial-state metrics. Because transaction creation is a single DynamoDB write (see Q6), there is no "transaction exists but frozen MCT is missing" failure mode to monitor — any metric of that shape would be vacuously zero.
- Inbound contract observation. Track the
%of inboundPOST /transactionspayloads carryingmaximum_completion_time_days(sliced bytracking_typeif useful). This is a contract signal — does upstream send MCT when we expect it? — not a failure-detection metric, and is independent of atomicity. - Transaction-creation failures. Reuse the existing creation-error monitor; once this spike lands, validation rejections (422 for malformed MCT) should be visible as a distinct error category so we can spot upstream regressions early.
- Eligibility-check failures on
/completed-goalv2. The new versioned endpoint that resolves the anchor and applies the MCT rule gets its own monitor for evaluation errors (rule missing where expected, anchor unresolvable — bothattributionTimeandclick.created_atnull — DynamoDB read failures during the check). - Expired-rate anomalies. Sudden spikes in goals failing
completion_time <= anchor + days * 86400sare an early signal of either an upstream MCT misconfiguration or a clock-skew issue. Likely modeled as a Datadog span on the eligibility check rather than a custom metric, so cardinality is bounded by trace-sampling. Where useful, tag the span with which anchor was selected (attribution_timevsclick.created_at) and whethercompletion_timewas caller-supplied vs defaulted tonow_utc. - Completion-time lag. Track the distribution of
now_utc - completion_timefor goal-completion requests wherecompletion_timeis caller-supplied. A long tail is expected (the cases this design exists to handle); a sudden distribution shift is an early signal of upstream delay incidents (MMP / Tune / Keystone slowdowns).
The detailed observability spec (metric names, alert thresholds, dashboards) lives in a follow-up doc — out of scope for this spike.
CONSEQUENCES
Positive
- Correctness: Players are evaluated against the rules they were originally promised, not the current (possibly edited) campaign config. This is the central correctness goal of the spike.
- Auditable disputes: The frozen MCT rule plus the persisted anchor inputs (
transaction.attributionTime,click.created_at) together reconstruct "what was promised", removing ambiguity in support cases without re-querying upstream historical state. Corrections ride the existingRewardOrigin::SUPPORT_RESOLUTIONlever (or fraud-withholding tooling) — the frozen rule itself is never mutated, preserving it as the audit-of-record. - Matches the dynamic anchor: Computing the deadline at check time means a downstream goal completion picks up
attribution_timeautomatically once the attribution goal has completed — no follow-up write needed. - Delayed-credit safe: The eligibility check compares against caller-supplied
completion_time(defaultnow_utc), so credit delays from support resolution, Tune approval lag, MMP / Keystone / Postback Processor outages, etc. don't falsely expire goals that were actually completed in-window. Auto-completed synthetic/dummy goals inherit their prerequisite'scompleted_atfor the same reason. - No new DynamoDB writes or reads: Frozen rule is captured as part of the existing transaction-creation write and is loaded alongside the Transaction at conversion time.
- Atomicity is free: Transaction + frozen MCT persist together in a single
PutItem, so there is no partial-state failure mode where a Transaction exists without its rule. - Negligible payload growth: ~10 bytes per goal × N goals is far below DynamoDB's 400KB item limit.
- Path to retire the global TTL: The per-goal MCT deadline replaces today's
config('playerapi.player-offers-ttl')heuristic with a contractual rule. - No upstream pull: Player API persists what it receives in the existing
POST /transactionscall — no new outbound dependency on the Adaction main API.
Negative
- Per-option overhead (full breakdown in Considered Options): adds one field on
GoalDataand a small amount of compute (anchor resolution + day math) at every eligibility check. - Day arithmetic at read time: Recomputing
anchor + dayson every check is fine at O(1) but is more error-prone than reading a stored timestamp. Centralizing the math in a singleGoalEligibilityServicehelper mitigates this. - Status migration follow-up:
TransactionData::calculateStatus()still uses a global TTL inAmerica/New_York. Migrating it to use the per-goal MCT rule is out of scope for this spike (tracked as OQ3) but is a prerequisite to fully deprecating the global TTL.
Risks
- Silent overwrite on re-create (OQ1): A second
POST /transactionsfor the sametransaction_idwith a different MCT could overwrite the original frozen rule. Mitigation: keep the first frozen rule and emit a structured log line; confirm with upstream team. - Anchor-ordering ambiguity (OQ5): For attribution-goal campaigns, a downstream goal that completes before the attribution goal has
transaction.attributionTime = nullat evaluation time and would fall back toclick.created_at. Whether that fallback is product-correct (vs. failing closed until attribution is set) is open — see OQ5. - Tracking-type ambiguity (OQ4): For
tracking_type = CPC(no goal completion), MCT may not be meaningful. Mitigation: clarify per-tracking_typerequirements before makingmaximumCompletionTimeDaysnon-optional.
NOTES
Open Questions / Follow-ups
- OQ1 — Idempotency on re-create. If the upstream sends a second
POST /transactionsfor an existingtransaction_idwith a different MCT, do we (a) reject as conflict, (b) keep the first frozen rule, or (c) accept the new one and audit the old? Recommend (b) with a structured log line; confirm with upstream team. - OQ2 — Backfill. How do we treat transactions created before this feature ships? Since the new field will be nullable, there is no need for backfilling.
- OQ3 — Migrating
TransactionData::calculateStatus(). Today it uses a global TTL inAmerica/New_York. After this spike lands, we should migrate it to use the per-goal MCT rule (with the sameanchor + daysevaluation as the conversion path). Tracked separately. - OQ4 — Per-event-type semantics. For
tracking_type= CPC (click-only, no goal completion), is MCT meaningful? Probably not — clarify whethermaximumCompletionTimeDaysis required or optional based ontracking_type. - OQ5 — Anchor selection when the attribution goal hasn't completed yet. For attribution-goal campaigns, a downstream goal that completes before the attribution goal sees
transaction.attributionTime = nulland the??falls back toclick.created_at. Two interpretations are possible: (a) the pseudocode is intentional — click time is the right fallback because the only fact we have — or (b) the rule should fail closed (no eligibility until the attribution goal completes). Confirm with product. Recommend (a) for v1, since it matches the proposed code and avoids stalling rewards on race conditions, but capture this explicitly because the answer affects downstream-goal payout timing. - OQ6 — Staleness cap on
completion_time. Should there be an upper bound on how far behindnow_utca caller-suppliedcompletion_timecan be (e.g. "must be within N days")? Not required for correctness, but useful to bound the blast radius of a buggy or malicious caller back-dating credits. Recommend leaving uncapped for v1: the existingRewardOrigin::SUPPORT_RESOLUTIONpath already provides a documented escape hatch for legitimate retroactive credits, so unbounded back-dating via the regular goal-completion path is unlikely to be needed. Revisit if thenow_utc - completion_timedistribution (see Observability) shows large lags as a normal pattern.
Out of Scope
- Fetching campaign/goal config from the upstream API (snapshot is pushed, not pulled).
- Changes to the upstream Adaction main API's goal model.
- UI/admin tooling to display snapshots (separate ticket).
References
- PR #144: docs(ADR-[Number]): Enforce MCT snapshot in Player Api
- PR #180: fix(adr): strip literal [Number] from ADR filenames and sidebar
Original Author
mcornejo