Skip to content

feat: Full Stack v12 — safe traffic ramp-up, variation preview & experiment exclusion - #54

Merged
abbaseya merged 27 commits into
mainfrom
feat/fullstack-v12
Jul 24, 2026
Merged

feat: Full Stack v12 — safe traffic ramp-up, variation preview & experiment exclusion#54
abbaseya merged 27 commits into
mainfrom
feat/fullstack-v12

Conversation

@abbaseya

Copy link
Copy Markdown
Contributor

Full Stack v12 — next major SDK release

This branch delivers three major capabilities, shipped consistently across all Full Stack SDKs. All three are inert until activated on the backend/UI side, so this deploys safely in any order.

1. Anchored (ramping) bucketing layout

Makes changing an experiment's total traffic allocation safe on a running experiment.

  • Each variation is pinned at its own fixed offset (cumulative-weight anchor); its range width equals its allocation. Raising the total grows every arm at its own edge (even growth); lowering ejects evenly; nobody already bucketed gets reshuffled.
  • Gated on the experience's existing serving version property: > 11 → anchored pass (contract v12); ≤ 11 / missing → packed pass, bit-identical to today. No schema change, zero migration.
  • Hash, seed, stored-decision guard, forced path, and events are untouched. Still stateless, deterministic, and identical across every SDK — enforced by shared cross-SDK golden vectors.
  • Changing variation weights mid-flight stays unsupported (unsafe in every layout); the split lock is unchanged.

2. Experiment preview

Lets users preview any variation — including drafts and paused ones — in their own environment, with zero trace.

  • New debugToken config option (24h QA token) unlocks the full config, including draft/paused experiences.
  • Stateless preview links (web) and deep links (mobile) via convert_preview={expId}.{varId} force a single variation with no side effects: no tracking events, no visitor-state writes, no impact on reporting.
  • Rides the existing public serving primitive — no backend serving changes required.

3. Mutual exclusion / inclusion audience rule

Lets teams control which experiments a visitor can be in at the same time, through the normal audience-targeting rules.

  • New audience rule bucketed_into_experience_key, resolved against SDK-stored bucketing state.
  • negated: true → "NOT in experiment X" (mutual exclusion); default → "in experiment X" (inclusion).
  • No new application inputs; evaluates before bucketing and composes identically with both packed and anchored layouts. Old SDKs fail closed.

Notes

  • Backend registration of the new rule type and the version 12 stamp, plus the related convert-ui work (preview link/QR button, rule picker) and shared docs, are tracked and coordinated separately.
  • Full existing test suites remain green; the anchored pass adds the shared golden-vector acceptance tests.

abbaseya and others added 25 commits July 3, 2026 22:44
Copied verbatim from javascript-sdk origin/feat/anchored-bucketing-layout
(sha256 9993d0e6...). 59 vectors, versions {11,12}. Cross-SDK contract oracle.

Beads: ai-driven-product-dev-mq9v

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AC1-AC9 unit tests + golden-vector #[DataProvider] runner (59 vectors).
RED: 12 v12-anchored golden failures + 7 AC failures; v11 packed passes.

Beads: ai-driven-product-dev-51e7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Anchored pass beside packed walk, gated on experience.version > 11 in
DataManager fresh-bucketing branch (<=11/missing/non-numeric -> packed,
bit-for-bit unchanged). New BucketingManager getBucketRanges/
selectBucketAnchored/getBucketForVisitorAnchored (+ interface). Shared
hash/seed/value path unchanged. Ships inert.

Golden 59/59 + AC 14/14 pass; full suite 1096 tests 0 failures;
PHPStan level 6 clean. Ships inert (backend stamps version 11).

Beads: ai-driven-product-dev-51e7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… gate)

Hoist repeated 3-arm variation configs into thirds()/tenEightyTen() private
factories; removes the >=10-line duplicate blocks CPD would flag. Test-only:
14 AC tests + 59 golden vectors still GREEN, assertions unchanged.

Beads: ai-driven-product-dev-8dsf

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces the anchored bucketing layout as a version-gated pass
(experience.version > 11) beside the existing packed walk, and extends the
public BucketingManagerInterface with getBucketRanges, selectBucketAnchored,
and getBucketForVisitorAnchored. Cross-SDK parity with the JavaScript SDK
reference is enforced by the shared golden-vector fixture.

Ships inert: the backend currently stamps every experience version 11, so no
served experience routes to anchored until the coordinated backend bump to 12.
The packed path (version <= 11) is bit-for-bit unchanged.

BREAKING CHANGE: bucketing contract v12 introduces the anchored layout and
extends the public BucketingManagerInterface. Consumers implementing that
interface must add the new methods. Runtime behavior is unchanged until the
backend stamps experience.version > 11.
…aManager

Dedupes the running/non-zero-traffic predicate shared by the packed and
anchored bucket builders into DataManager::isVariationActive() (addresses
PR #50 review).
Beads: ai-driven-product-dev-wxmk
Agent: sdk-php
AC1/AC2 red (4 failures), AC3 hygiene tests in place

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-wxmk
Agent: sdk-php
AC1 transport + AC2 cache-elim + AC3 hygiene; 10/10 green, full suite 1106 pass, PHPStan L6 clean

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-1y2b
Agent: sdk-php
AC4-AC9 red (23 errors: undefined PreviewParam/getConfigForExperience/setPreview)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-1y2b
Agent: sdk-php
setPreview + PreviewParam/PreviewResolver + buildPreviewDecision + per-context suppressPersistence
signal; AC4-AC9 green (23/23), full suite 1129 pass 0 fail 34 skips, PHPStan L6 clean, cs-fixer clean.
Corrected 2 ?exp= mock fixtures (removed erroneous data-envelope to match real serving wire format).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eature-path leak (§2)

Beads: ai-driven-product-dev-1y2b
Fix defect 1 (HARD §2): runFeature/runFeatures now forward suppressPersistence when preview set
  (Context.php 342/441), mirroring runExperience/runExperiences — closes the FeatureManager->
  getBucketing persist+enqueue leak invisible to the mocked AC6 test.
Fix defect 2: real-FeatureManager zero-trace regression test across shutdown flush (was RED, now GREEN).
Fix defect 3 (§3): runExperiences applies preview-forcing override for in-config running target.
Swept all 4 run-family BucketingAttributes sites; no sibling leak remains. Full suite 1131 pass,
PHPStan L6 clean, cs-fixer clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n messages (AC3)

Beads: ai-driven-product-dev-f808
Redact-at-source in ApiManager catch block: $e->getMessage() (which carries the URL+token on
Guzzle-style transport errors) now redacted once for both the log 'error' field and the rethrown
RuntimeException; Core/PreviewResolver receive already-redacted messages. Strengthened the
network-error regression (was trivially passing on a URL-less exception) + symmetric preview-fetch
case. Full suite 1132 pass, PHPStan L6 clean, cs-fixer clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-1y2b
Documents the new public surface (debugToken config option, $context->setPreview,
PreviewParam::parse + convert_preview link format) in the SDK README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eview key

Addresses two PR #51 code-review findings:

- ApiManager::redactDebugTokenForLog now redacts the debug_token query-param
  value via regex (/(debug_token=)[^&\s]*/i) instead of exact-matching the
  urlencode() form. The method runs on arbitrary PSR-18 client exception
  messages (getMessage()), so a client using rawurlencode/decoded URLs/lower
  percent-encoding would previously defeat the match and leak the token
  (AC3 token hygiene must hold regardless of HTTP client).
- Context::runExperiences preview injection now sources the key from the
  built decision (previewDecision['experienceKey']) and guards null/empty,
  so a bucketed variation with a missing experienceKey can no longer
  spuriously match null === null and be overwritten by the preview decision.

Tests: encoding-agnostic redaction (rawurlencode + decoded, trailing param)
and null-preview-key non-overwrite; both fail against the pre-fix code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(bucketing)!: anchored bucketing layout (contract v12) — PHP SDK
…e enum

Beads: ai-driven-product-dev-sn3t
Agent: sdk-php
Adds ErrorMessages::BUCKETING_EXCLUSION_TARGET_NOT_FOUND (# placeholder) and
RuleType backed enum (BucketedIntoExperienceKey = 'bucketed_into_experience_key').
Unit test 2/2 green. RuleManager untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-wtpu
Agent: sdk-php
Contract-exact 8-row fixture (MutualExclusionFixture::rows, named-key provider) +
mutual-exclusion-config.json (exp-a 100111/100901, exp-b 100222/100902). Sanity 6/6.
Autoload note: fixture consumed via require_once (composer ConvertSdk\Tests\ maps only Utils/tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-qgtn
Agent: sdk-php
6 test files pinning matchRulesByField seam: RuleResolution (8-row provider AC1/4/8),
ReadOnly (AC5 spies), Combination (AC6 ALL/ANY), Integration (AC2/4 public context),
Persistence (AC3/row8 new-instance). All RED for genuine architectural reasons
(:410 gate skip, missing resolution). No src/ changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… seam

Beads: ai-driven-product-dev-qgtn
Agent: sdk-php
DataManager-side synthetic-delegation seam: resolve bucketed_into_experience_key
read-only (getEntity + getData presence check, negation, AC8 warn), route through the
UNTOUCHED RuleManager::isRuleMatched via a synthetic equals rule. Gate at :410 widened
only on presence of a sole exclusion rule (AC4); generic-only path byte-identical (AC7).
RuleManager.php + DataManagerInterface.php byte-unchanged. 22/22 new suites green,
full suite 1159 green, phpstan L6 clean, cs-fixer clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-o6nx
Agent: sdk-php
9 targeted vectors (26 assertions) locking generic text/numeric/bool rules match
bit-identically through filterMatchedRecordsWithRule (visitorId passed AND null),
generic-only empty-props audience STILL excluded (gate widening does not leak, AC7
counterpart to AC4), and multi-element trees never mistaken for a sole exclusion rule.
No src/ changes. Full suite 1168/4439 green; configured phpstan gate (src) clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Messages.php

Beads: ai-driven-product-dev-sn3t
Agent: sdk-php
Decision-audit FAIL (narrow): BUCKETING_EXCLUSION_TARGET_NOT_FOUND is a warn-level
'not found in config' message, semantically belongs in Messages.php beside
NULL_RETURN_EXPERIENCE_NOT_FOUND / PREVIEW_EXPERIENCE_NOT_FOUND, not ErrorMessages.php
(error conditions). Moved constant + updated consumer (DataManager:1537) + PHP-1 test.
Runtime behavior unchanged (AC8 message text identical). Targeted 11/25, full 1168/4439,
phpstan gate clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… breadcrumb

Beads: ai-driven-product-dev-qgtn
Agent: sdk-php
Round-2 auditor caught an inert stale 'ErrorMessages::' breadcrumb in an assertion
message string (test only, not imported); relocation left it factually false. Swept to
Messages::. grep for old FQN now zero hits; resolution suite 8/8 green. Objective PASS
criterion (zero stale hits) met.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ual-exclusion path (Gemini review R1)

Three defensive fixes in packages/Data/src/DataManager.php surfaced by
Gemini code review round 1 on the qs-03 mutual-exclusion-audience-rule
seam (bucketed_into_experience_key):

- matchRulesByField() (DataManager.php:433): the qs-03 gate widening at
  line 425 (`if ($visitorProperties || $hasBucketingExclusionAudience)`)
  lets execution reach filterMatchedRecordsWithRule() even when
  $visitorProperties is genuinely null (no caller-supplied properties
  at all), but that call's non-nullable `array $visitorProperties`
  parameter (line 1379) threw a reachable TypeError. Now passes
  `$visitorProperties ?? []` — safe because the exclusion path reads
  stored bucketing state via getData(), not visitorProperties content.
- filterMatchedRecordsWithRule() (DataManager.php:1413): guarded
  `$item['rules']` with `is_array(...)` before calling
  _findSoleBucketedIntoExperienceKeyRule(), matching the codebase's
  existing is_array() pattern (DataManager.php:408). A malformed
  config with a non-array `rules` value now falls through to the
  generic path instead of hitting a strict-type violation.
- _audiencesContainBucketedIntoExperienceKeyRule() (DataManager.php:1463):
  same is_array($audience['rules']) guard added alongside the existing
  !empty() check.
- _buildBucketedIntoExperienceKeyRuleMatch() (DataManager.php:1543):
  `(string)$target['id']` changed to `(string)($target['id'] ?? '')`,
  mirroring the method's own existing `(string)($rule['value'] ?? '')`
  pattern (line 1531), to avoid an undefined-array-key warning if a
  resolved experience entity lacks an `id` key.

New regression test: MutualExclusionRuleResolutionTest::
testNullVisitorPropertiesDoesNotThrowOnNegatedExclusion() in
packages/Data/tests/MutualExclusionRuleResolutionTest.php — mirrors
fixture row 4 (bucketed into exp-a, negated exclusion rule targeting
exp-a, expectedMatched=false) but constructs BucketingAttributes
without ever setting visitorProperties, so the internal field is
genuinely null instead of the `[]` every other AC4 test in this file
uses. Confirmed RED (TypeError at DataManager.php:431) before the
fixes, GREEN after. This closes the actual coverage gap that let the
first bug ship: the AC4 tests only ever exercised the empty-array
caller path, never the true-null caller path that
BucketingAttributes::visitorProperties genuinely allows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(preview): qs-02 experiment preview — debug token + preview links
feat(rules): mutual-exclusion audience rule (bucketed_into_experience_key)
@abbaseya abbaseya self-assigned this Jul 16, 2026
@abbaseya
abbaseya requested a review from DmytroConvert July 16, 2026 16:27

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces major features to the PHP SDK, including Experiment Preview, QA debug token support, Anchored Bucketing Layout (v12), and mutual-exclusion audience rules (bucketed_into_experience_key). The preview context ensures a zero-trace guarantee, suppressing persistence and tracking across the lifecycle. The review feedback highlights two critical issues: a missing import of LogUtils in DataManager.php that will cause a runtime Fatal Error on warning paths, and a hardcoded scaling factor (* 100) in BucketingManager::getBucketRanges() that ignores the configurable maxTraffic property.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread packages/Data/src/DataManager.php
Comment thread packages/Bucketing/src/BucketingManager.php
@abbaseya abbaseya changed the title Full Stack v12: safe traffic ramp-up, variation preview & experiment exclusion feat: Full Stack v12 — safe traffic ramp-up, variation preview & experiment exclusion Jul 16, 2026
The Laravel demo now demonstrates both qs-16 QA/preview capabilities:

- ConvertContext middleware parses ?convert_preview={experienceId}.{variationId}
  via PreviewParam::parse() and calls $context->setPreview() before the context
  is handed to controllers, so any page renders the forced variation
  server-side with zero tracking/persistence. No-op on missing/malformed param.
  Skips the demo's own new-visitor cookie while previewing so a stakeholder
  preview request stays stateless.
- debugToken surfaced via CONVERT_DEBUG_TOKEN env -> config('convert.debug_token'),
  passed to ConvertSDK::create() only when non-empty.
- README gains a "Preview Links & QA" section: URL format, example, where to get
  the numeric ids, ?exp= draft/paused auto-fetch, zero-trace verification, and
  the debug-token option.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DmytroConvert
DmytroConvert previously approved these changes Jul 21, 2026
…tion)

Cross-SDK release-review parity fix (JS oracle): suppress the in-process BUCKETING, CONVERSION, and LOCATION events while a preview is active on a context (network and persistence suppression already existed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@abbaseya
abbaseya merged commit fb98020 into main Jul 24, 2026
11 checks passed
@abbaseya
abbaseya deleted the feat/fullstack-v12 branch July 24, 2026 16:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants