feat: Full Stack v12 — safe traffic ramp-up, variation preview & experiment exclusion - #54
Conversation
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)
There was a problem hiding this comment.
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.
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>
…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>
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.
versionproperty:> 11→ anchored pass (contract v12);≤ 11/ missing → packed pass, bit-identical to today. No schema change, zero migration.2. Experiment preview
Lets users preview any variation — including drafts and paused ones — in their own environment, with zero trace.
debugTokenconfig option (24h QA token) unlocks the full config, including draft/paused experiences.convert_preview={expId}.{varId}force a single variation with no side effects: no tracking events, no visitor-state writes, no impact on reporting.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.
bucketed_into_experience_key, resolved against SDK-stored bucketing state.negated: true→ "NOT in experiment X" (mutual exclusion); default → "in experiment X" (inclusion).Notes
version12 stamp, plus the related convert-ui work (preview link/QR button, rule picker) and shared docs, are tracked and coordinated separately.