Skip to content

fix(context): forward every per-call bucketing attribute on the feature path - #63

Merged
abbaseya merged 9 commits into
mainfrom
fix/per-call-bucketing-attributes
Sep 17, 2026
Merged

abbaseya merged 9 commits into
mainfrom
fix/per-call-bucketing-attributes

Conversation

@abbaseya

@abbaseya abbaseya commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What this fixes

The two feature entry points rebuilt the attributes DTO from a five-key literal, so four controls were accepted by the public BucketingAttributes DTO and then dropped before reaching the engine — silently, with a plausible-looking result. The experience entry points have forwarded the object whole since b9e692b; these now do the same.

The sharpest consequence was ignoreLocationProperties. In PHP, a call with no locationProperties rejects the experience even when it carries no location restrictions at all — the "not restricted ⇒ matched" branch sits inside a block guarded on truthy locationProperties, so with none supplied it is unreachable. That makes the flag the only way a caller with no URL gets past the gate, and it was exactly the flag the feature path dropped. A CLI worker, queue consumer or webhook handler either fabricated a URL or was told the feature was disabled.

runFeatures additionally never passed an experience filter at all, so experienceKeys was accepted and ignored on the run-all call while runFeature honoured it.

Capability Change
CAP-1 runFeature/runFeatures spread the caller's object — enableTracking, forceVariationId, ignoreLocationProperties, suppressPersistence now reach the engine
CAP-2 runFeatures passes ['experiences' => …] as FeatureManager::runFeatures' third argument
CAP-3 Preview zero-trace survives a caller passing enableTracking: true / suppressPersistence: false
CAP-4 The four Context docblocks name the control set (partially delivered — see below)

Two behaviour changes for existing callers

1. enableTracking: false now works on the feature path. A caller passing it to runFeature/runFeatures has been tracked anyway; after this they are not. Tracked-exposure volume will drop for such callers. The returned decision and the sticky write are unaffected — persistence is gated on suppressPersistence alone.

2. forceVariationId now binds on the feature path — and writes through. This one is larger and the spec does not call it out. A force that disagrees with a stored decision takes the recompute path, so the forced variation becomes the visitor's new sticky decision and a bucketing event is enqueued. A caller who has been passing forceVariationId to a feature entry point ineffectively will now start overwriting their visitors' stored decisions. Nothing in the repo covered this on either path; testForceVariationIdDisagreeingWithStoredDecisionWritesThrough now does.

Both ship as fix:-scoped subjects, so they appear under Bug Fixes in the generated release notes.

Test evidence

Suite / gate Result
cross-sdk (parity) 474 tests, 651 assertions, OK
full (composer test) 1198 tests, 4552 assertions, OK, 34 skipped
composer analyze PHPStan level 6, no errors
composer cs-check 0 of 133

Baseline at the fork point 028e373 was 1174 / 4465 / 34 skipped and 130 files. The branch adds 24 cases and 3 files, so the arithmetic closes exactly and the skip count is unchanged — the 34 are pre-existing live-staging cases needing CONVERT_STAGING_SDK_KEY, not anything this change caused.

Twelve of the new cases fail against main, verified by replaying the test files against a throwaway clone at the fork point. The ones that pass pre-implementation are declared rather than hidden: constraint guards (the "no flag" and string-'true' rows, the absent/empty-array filter rows) and the preview guard, which passes today only because the caller's values are dropped before reaching the engine.

Every safety property was mutation-proven rather than asserted — the preview override reversed, the third argument removed, the suppressPersistence gates flipped one at a time, and the forceVariationId disagreement guard neutered. Each failed the expected test, and each source file was restored and verified clean.

Scope notes for the reviewer

CAP-4 is deliberately partial. Its success criterion names both of PHP's attribute tables. php-sdk.wiki/CodeExamples.md is not updated, and should not be: a wiki page is never a deliverable of the feature that changed the code it describes — the daily drift routine owns that refresh, and a wiki clause inside a spec is a defect in the spec rather than a task. Please read the delivery as complete-minus-that-clause, not as an oversight. The spec's definition of done needs correcting.

Because of that, and because the shared backend row omits it, the PHPDoc is now the only surface in the project stating that only the boolean true bypasses the location gate. That matters: the documented construction idiom is the constructor array, which coerces nothing, so ['ignoreLocationProperties' => 'true'] constructs fine and then silently does not work.

FeatureManagerInterface::runFeatures' $filter docblock is corrected here (daf9f7e) although it predates this work. It documented the key as experienceKeys while every implementation reads experiences, and this PR makes Context::runFeatures the first caller anywhere to pass a filter — so a later "fix" aligning the call site with the docblock would silently turn narrowing back into run-all, and phpstan.neon suppresses argument.type for Context.php, so nothing would catch it.

Two known-minor items left unfixed, disclosed rather than carried silently: in ContextFeatureBucketingAttributesTest.php, the assertGreaterThan(0, $rig['dataStore']->setCalls, …) is redundant because the preceding natural call already incremented the counter, and one assertion message says "return" where it inspects the store. Both scored ~55 in review, below the 75 threshold this run acted on; the load-bearing assertion two lines later already carries the property.

Related

backend #7391 — the shared full-stack-docs half of CAP-4. Its run-all note said experienceKeys was "available in the JavaScript SDK for this call", which was accurate until CAP-2 here falsified it; commit 9f2570d56a on that PR restores PHP to the note and adds the strict-boolean clause. That PR should merge alongside this one — merged alone it publishes a claim this PR makes false into all six SDK wikis.

Decision records

  • AgDR-0171-suppresspersistence-becomes-caller-reachable-on-the-php-feature-path
  • AgDR-0172-reconcile-a-sibling-runs-work-against-the-pr-head-not-a-branch-commit

🤖 Generated with Claude Code

Effort

Agent effort — per-call-bucketing-attributes-specs

Agent Task Time Output Cached
feature-conductor Implement per-call bucketing attributes 2h 34m 02s 278,279 120,617,718
feature-conductor Conduct PHP SDK per-call attrs feature 1h 22m 52s 186,572 69,367,449
spec-auditor Audit bundle B-1-A 19m 26s 125,272 2,473,305
spec-auditor Audit Python SDK bundle B-1-A 17m 59s 118,291 2,483,096
spec-auditor Audit bundle B-1-A 17m 56s 111,216 2,219,414
sdk-php SDK-1 RED tests 16m 54s 48,810 5,258,391
spec-auditor Audit PHP SDK bundle B-1-A 15m 54s 106,125 2,241,108
decision-analyst Decision audit — analyst 15m 40s 73,063 11,152,538
spec-auditor Audit Ruby SDK bundle B-1-A 15m 02s 98,182 1,278,816
spec-auditor Audit the resolved spec 14m 38s 77,555 3,462,674
sdk-test-writer SDK-1 RED tests 14m 13s 70,323 12,341,924
spec-auditor Audit bundle B-1-A 14m 01s 77,546 3,417,327
convert-code-reviewer Code review 13m 58s 57,079 10,721,870
spec-auditor Audit Android SDK bundle B-1-A 13m 41s 88,099 1,813,982
cross-scope-auditor Cross-scope consistency pass 12m 51s 63,583 1,822,187
decision-analyst Decision audit analyst 12m 50s 54,280 6,998,081
decision-auditor Round 2 audit verdict 12m 35s 58,186 5,942,398
cross-scope-auditor Cross-scope pass on resolved spec 10m 45s 51,146 1,370,795
cross-scope-auditor Ruby SDK cross-scope pass 10m 38s 55,685 2,036,166
cross-scope-auditor Cross-scope consistency pass 10m 23s 53,200 1,114,528
decision-auditor Decision audit — independent auditor 9m 49s 47,526 4,236,372
cross-scope-auditor Android SDK cross-scope pass 9m 12s 44,171 1,647,328
sdk-test-writer Fix CAP-3 over-claiming test titles 8m 55s 33,837 7,552,925
decision-auditor Decision audit refuter 8m 54s 39,680 5,260,326
cross-scope-auditor Python SDK cross-scope pass 8m 41s 47,206 1,014,999
sdk-test-writer Review round 1 test fixes 8m 36s 38,393 11,726,243
convert-code-reviewer Code review round 2 8m 30s 30,342 4,066,799
sdk-test-writer Write CAP-1/CAP-3 PHP SDK tests 8m 20s 45,024 8,223,458
readiness-assessor Readiness assessment 8m 06s 40,221 4,609,183
cross-scope-auditor PHP SDK cross-scope pass 7m 55s 42,619 988,536
cross-scope-auditor Cross-scope consistency pass 7m 55s 39,174 1,256,173
sdk-test-writer SDK-2 RED tests 7m 27s 40,376 2,982,488
sdk-test-writer Round 2 test strengthening 7m 19s 36,617 6,282,217
readiness-assessor Readiness assessment 6m 47s 34,022 4,490,569
sdk-test-writer SDK-2 CAP-2 RED tests 6m 19s 29,669 3,844,230
convert:premise-challenger Challenge the input premise 6m 17s 28,358 2,113,251
sdk-test-writer Move CAP-3 tests into new files 6m 06s 26,276 3,324,254
sdk-test-writer SDK-1 browser CAP-3 gap 6m 00s 20,691 4,698,422
sdk-js Remove the two added comment lines 4m 47s 10,779 2,937,342
general-purpose Residual fixer F-006 4m 44s 32,957 1,282,054
docs-sync Docs sync 4m 34s 15,378 2,003,567
feature-conductor Implement PHP per-call bucketing attrs 4m 12s 15,796 4,547,003
general-purpose Residual fixer F-014 3m 48s 27,077 602,364
sdk-php DOC-1 PHPDoc 3m 38s 20,741 1,581,562
sdk-test-writer SDK-0 retry after gate unblock 3m 24s 13,675 1,493,802
sdk-js SDK-1 GREEN implementation 3m 19s 7,586 2,404,479
sdk-test-writer CAP-2 baseline assertion gap 3m 18s 11,753 2,444,488
sdk-test-writer SDK-0 preview helper extraction 3m 12s 15,924 1,696,112
sdk-js Trim context.ts comment budget 3m 06s 12,320 2,052,677
sdk-js SDK-2 CAP-2 implementation 2m 54s 6,126 2,534,645
general-purpose Residual fix F-008 2m 26s 13,567 1,619,268
general-purpose Patch cluster C-013 2m 21s 13,587 859,611
tracking-dev DOC-1 shared SDK docs rows 2m 17s 12,592 829,483
general-purpose Residual fix F-005 2m 13s 15,367 658,310
general-purpose Patch cluster C-004 2m 12s 12,579 660,874
general-purpose Patch cluster C-011 2m 06s 10,689 1,343,168
general-purpose Patch cluster C-009 2m 00s 10,622 600,916
general-purpose Patch cluster C-005 1m 49s 4,296 1,234,113
general-purpose Patch cluster C-001 1m 48s 8,970 1,338,450
sdk-js Retry context.ts comment trim 1m 47s 5,205 1,425,987
general-purpose Patch cluster C-010 1m 46s 8,620 508,953
general-purpose Residual fix F-013 1m 43s 9,458 800,625
general-purpose Patch cluster C-010 1m 39s 9,569 1,073,636
general-purpose Patch cluster C-001 1m 38s 9,278 859,328
general-purpose Patch cluster C-012 1m 37s 9,269 735,953
general-purpose Patch cluster C-002 1m 32s 8,011 429,035
general-purpose Patch cluster C-010 1m 24s 8,127 619,571
general-purpose Patch cluster C-001 1m 23s 3,773 1,050,034
general-purpose Patch cluster C-007 1m 22s 6,630 778,254
general-purpose Patch cluster C-004 1m 20s 7,962 711,019
general-purpose Patch cluster C-004 1m 20s 4,856 950,803
general-purpose Patch cluster C-002 1m 20s 5,547 1,052,455
general-purpose Patch cluster C-008 1m 19s 5,858 431,101
sdk-php SDK-2 GREEN implementation 1m 19s 3,755 1,380,581
general-purpose Patch cluster C-011 1m 19s 7,154 815,041
general-purpose Patch cluster C-008 1m 18s 7,310 722,256
sdk-php SDK-1 GREEN implementation 1m 17s 5,813 1,164,329
general-purpose Patch cluster C-004 1m 17s 6,299 531,036
general-purpose Patch cluster C-007 1m 15s 6,472 941,342
general-purpose Patch cluster C-009 1m 14s 4,952 838,235
general-purpose Patch cluster C-003 1m 13s 4,440 1,141,664
general-purpose Patch cluster C-006 1m 12s 6,961 525,336
general-purpose Patch cluster C-001 1m 10s 6,322 901,977
general-purpose Patch cluster C-007 1m 09s 7,120 531,184
general-purpose Patch cluster C-003 1m 04s 5,450 429,649
general-purpose Patch cluster C-006 1m 04s 6,209 522,270
general-purpose Patch cluster C-010 1m 01s 3,893 951,913
sdk-php Interface docblock fix 1m 01s 2,909 930,376
general-purpose Patch cluster C-002 1m 01s 5,298 627,318
general-purpose Patch cluster C-013 59s 4,838 611,676
general-purpose Patch cluster C-006 59s 5,605 715,996
general-purpose Patch cluster C-002 56s 5,701 613,413
general-purpose Patch cluster C-006 55s 3,542 506,193
general-purpose Patch cluster C-001 53s 4,790 711,918
general-purpose Patch cluster C-005 52s 3,917 619,087
general-purpose Patch cluster C-012 52s 4,154 631,183
general-purpose Patch cluster C-002 51s 4,663 808,816
general-purpose Patch cluster C-003 51s 4,314 546,791
general-purpose Patch cluster C-012 50s 3,412 507,252
general-purpose Patch cluster C-005 50s 4,882 530,541
general-purpose Patch cluster C-003 49s 5,013 523,391
general-purpose Patch cluster C-007 48s 5,540 660,163
general-purpose Patch cluster C-007 47s 4,822 439,060
general-purpose Patch cluster C-004 47s 4,519 719,475
general-purpose Patch cluster C-006 46s 3,426 747,628
general-purpose Patch cluster C-007 46s 3,401 346,011
readiness-assessor Readiness assessment 44s 2,219 599,667
general-purpose Patch cluster C-009 44s 4,467 432,647
general-purpose Patch cluster C-009 43s 4,276 608,253
general-purpose Patch cluster C-008 42s 4,022 528,164
general-purpose Patch cluster C-008 42s 2,856 659,990
general-purpose Patch cluster C-011 40s 2,665 423,959
general-purpose Patch cluster C-003 39s 3,634 514,620
general-purpose Patch cluster C-014 39s 961 424,958
general-purpose Patch cluster C-001 38s 3,027 345,217
general-purpose Patch cluster C-013 38s 2,377 412,505
general-purpose Patch cluster C-005 36s 2,012 492,705
general-purpose Patch cluster C-005 35s 3,446 532,299
general-purpose Patch cluster C-005 35s 3,176 527,485
general-purpose Patch cluster C-008 35s 3,619 428,250
general-purpose Patch cluster C-012 35s 3,149 432,240
general-purpose Patch cluster C-009 34s 2,796 634,067
tracking-dev DOC-1 forceVariationId semantics gap 33s 2,004 614,783
general-purpose Patch cluster C-004 33s 2,685 620,760
general-purpose Patch cluster C-011 32s 2,807 522,444
general-purpose Patch cluster C-001 32s 3,475 429,050
general-purpose Patch cluster C-010 32s 2,716 431,299
general-purpose Patch cluster C-002 32s 3,092 532,400
general-purpose Patch cluster C-015 32s 3,339 357,625
general-purpose Patch cluster C-002 31s 1,895 598,591
general-purpose Patch cluster C-009 30s 2,928 429,617
general-purpose Patch cluster C-006 30s 2,933 515,038
general-purpose Patch cluster C-003 29s 2,629 521,842
general-purpose Patch cluster C-010 28s 2,568 614,169
general-purpose Patch cluster C-008 27s 2,283 520,433
general-purpose Patch cluster C-007 27s 1,993 729,856
general-purpose Patch cluster C-011 27s 2,432 521,874
general-purpose Patch cluster C-009 26s 2,043 519,099
general-purpose Patch cluster C-005 26s 2,344 611,248
general-purpose Patch cluster C-004 23s 1,851 534,416
tracking-dev Backend doc correction 21s 1,855 473,970
tracking-dev DOC-1 two corrections 19s 2,111 372,833
general-purpose Patch cluster C-006 19s 1,732 342,354
general-purpose Patch cluster C-003 18s 665 418,605
general-purpose Patch cluster C-008 16s 1,509 347,835
tracking-dev DOC-1 positive-form scoping 16s 1,460 372,730
general-purpose Check subagent session id 4s 487 112,714
Total 147 task(s) 12h 57m 11s 3,182,672 435,858,294

By agent

Agent Tasks Time Output Cached
feature-conductor 3 4h 01m 06s 480,647 194,532,170
spec-auditor 8 2h 08m 37s 802,286 19,389,722
general-purpose 89 1h 33m 27s 495,977 57,546,338
sdk-test-writer 12 1h 23m 09s 382,558 66,610,563
cross-scope-auditor 8 1h 18m 20s 396,784 11,250,712
decision-auditor 3 31m 18s 145,392 15,439,096
decision-analyst 2 28m 30s 127,343 18,150,619
sdk-php 5 24m 09s 82,028 10,315,239
convert-code-reviewer 2 22m 28s 87,421 14,788,669
sdk-js 5 15m 53s 42,016 11,355,130
readiness-assessor 3 15m 37s 76,462 9,699,419
convert:premise-challenger 1 6m 17s 28,358 2,113,251
docs-sync 1 4m 34s 15,378 2,003,567
tracking-dev 5 3m 46s 20,022 2,663,799

Time = summed gaps between API responses. A gap after a turn ENDED is a wait on a human or a parent agent and is capped at 120s; a gap mid-turn is the agent generating or running its own tool and is counted up to 1800s, which bounds a hung tool without discarding a long test run. An orchestrator's time OVERLAPS the agents it spawned, so the total counts supervision as well as the work supervised. A task's own last response is followed by no gap, so an n-response task contributes n-1 intervals and its final generation is not counted. The orchestrating session is not a task and is not in the table. Cached is cache reads plus cache writes and is normally most of the prompt, because the same prefix is re-read on every response — it therefore tracks how OFTEN an agent was called as much as how much it handled. The UNCACHED prompt remainder is a small fraction of that, so it is not a column here; --json still carries it. Output excludes nothing. They bill at different rates, so a row is a volume, not a cost; /convert:cost without --per-task prices the run.

abbaseya and others added 8 commits September 15, 2026 15:02
…on the feature path

CAP-1 (SPEC-per-call-bucketing-attributes): 8 failing tests covering forwarding at
the FeatureManagerInterface hand-off, the ignoreLocationProperties location gate
(including the strict-comparison case), forceVariationId binding, and
enableTracking:false producing no enqueue while the sticky write still happens.

CAP-3: one new preview guard asserting zero trace survives a caller passing
enableTracking:true / suppressPersistence:false. It passes today by omission —
the caller's values are dropped before they reach the engine — so it is a guard
against the forwarding fix, not a RED test.

Tests only grow here: two new files, one appended method, zero existing lines
changed (41 insertions / 0 deletions).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e/runFeatures

The two feature entry points rebuilt the attributes DTO from a five-key literal,
so enableTracking, forceVariationId, ignoreLocationProperties and
suppressPersistence were accepted by the public DTO and then dropped before
reaching the engine — silently, with a plausible-looking result. They now spread
the caller's object, the same idiom the experience entry points have used since
b9e692b.

The sharpest consequence was ignoreLocationProperties: with no location
properties supplied, PHP rejects an experience even when it carries no location
restrictions, so a caller with no URL — a CLI worker, a queue consumer, a webhook
handler — was told the feature was disabled with no way to say otherwise.

Behaviour change for existing callers: enableTracking: false now suppresses the
bucketing enqueue on the feature path, where it previously had no effect. Callers
relying on that will see tracked-exposure volume drop. The returned decision and
the sticky write are unaffected — persistence is gated on suppressPersistence
alone.

The Context-set suppressPersistence preview override stays after the spread, so a
caller cannot re-enable tracking or persistence on a previewing context.

CAP-1, CAP-3 (SPEC-per-call-bucketing-attributes)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CAP-2 (SPEC-per-call-bucketing-attributes): 4 failing tests.

The forwarding assertion reads the spy's THIRD argument ($filter['experiences']),
never the captured DTO. Since CAP-1's spread now carries experienceKeys inside the
attributes object, a DTO-only assertion would pass today and stay green forever
while the filter is still null — the silent pass D-5 exists to prevent.

Also asserts the filter carries no 'features' key: the disabled-feature extension
that gives runFeatures its run-all contract is gated on that key's absence.

Two provider rows and the key-order test pass today. That is expected and stated
rather than hidden: for absent and empty-array input the correct behaviour equals
today's unfiltered behaviour, and order-invariance is a property of the entity
lookup that holds whether or not Context forwards a filter. All three remain
regression guards once CAP-2 lands.

Growth only: 148 insertions, 0 deletions, fixture untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
runFeatures accepted experienceKeys through the public attributes DTO and then
called FeatureManager with no filter at all, so a caller narrowing a run-all
feature call silently got every experience evaluated. runFeature has honoured the
same control all along, through its own positional argument.

The filter carries 'experiences' only. A 'features' key is never added: the
disabled-feature extension that gives runFeatures its run-all contract is gated
on that key's absence, so adding one would drop every feature the visitor was not
bucketed into. Narrowing therefore reports an excluded feature as disabled rather
than omitting it.

The keys are passed uniformly, including as null when the caller supplies none —
the downstream guard is an emptiness test, so null and [] both mean no filter.

CAP-2 (SPEC-per-call-bucketing-attributes)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four decision entry points described their attributes parameter as
'Attributes for the visitor' and named no control at all, so the controls this
branch makes reachable were undiscoverable from the IDE — which for a PHP SDK is
where a developer actually meets the documentation.

Each docblock now names the set it honours, with typeCasting and experienceKeys
marked inert on the experience path.

Two clauses are load-bearing rather than descriptive. Only the boolean true
bypasses the location gate: the documented construction idiom is the constructor
array, which coerces nothing, so 'true' as a string constructs fine and then
silently does not work. This docblock is now the only surface carrying that
caveat. And forceVariationId is explicitly not preview — it steers selection
inside normal gating, where preview bypasses every gate.

suppressPersistence stays undocumented while remaining reachable through the DTO.

CAP-4 (SPEC-per-call-bucketing-attributes)

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

Decision-audit round 2. All three blocks were added by this branch, so these are
still additions from main and no test-intent declaration applies.

The CAP-3 preview guard used assertInstanceOf(BucketedFeature::class) and
assertNotEmpty() as proof that bucketing happened. Neither does that job: a
feature that resolved to nothing still returns a disabled DTO, and runFeatures
pads its result with every declared feature when no feature filter is given. Both
now assert FeatureStatus::Enabled, so a total bucketing failure can no longer
satisfy the zero-trace assertions vacuously.

suppressPersistence was asserted nowhere, though D-2 knowingly makes it
caller-reachable on the feature path. It is now forwarded in the fixture and
covered behaviourally on a non-preview context: zero enqueues AND zero
visitor-state writes, which is the shape that distinguishes it from
enableTracking:false, where the sticky write still happens.

The key-order test compared two calls carrying the same key set, so it passed
whether or not the filter was applied. It now filters to a pair excluding the only
experience carrying feature-2 and asserts that feature Disabled in both orders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…onId write-through

Five code-review items, all in test paths, all growth on this branch.

forceVariationId's write-through had no coverage anywhere in the repo, on either
entry-point path. It is the only newly-reachable control that mutates persisted
visitor state: a force disagreeing with a stored decision takes the recompute
path, so the forced variation becomes the visitor's new sticky decision. The new
test buckets naturally, forces the other variation, asserts the store was
overwritten, then re-runs with no force and asserts the forced value comes back.
Proven by mutating the force guard and watching it fail.

The two counting/recording doubles moved to tests/Support/FeaturePathTestDoubles
behind a require_once, following the six-consumer pattern
packages/Data/tests/Support/MutualExclusionTestSupport.php already establishes —
ApiManagerInterface has 8 methods and both copies implemented all of them. Both
test files still run standalone, which is what the duplication existed to protect.

Two docblocks described what the code did BEFORE the fix landed on this same
branch and would have been false on merge; two others carried an alternative
considered and a restatement of the spec. Replaced with what the tests model,
cited by identifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The interface documented the filter as ['experienceKeys' => ['exp1']]. Every
implementation reads $filter['experiences'], so a caller following the docblock
would build a key nothing reads and get run-all instead of narrowing, with no
error. The declared value type also excluded null, which is what the new
Context::runFeatures call site passes when the caller supplies no keys.

Dormant until now — nothing passed a filter to this method before this branch.
Context::runFeatures is the first caller, and phpstan.neon suppresses
argument.type for Context.php, so a later alignment of the call site to the
docblock would not be caught.

CAP-2 (SPEC-per-call-bucketing-attributes)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@abbaseya abbaseya self-assigned this Sep 15, 2026

@JosephSamirL JosephSamirL 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.

Review — convertcom/php-sdk PR #63 @ c4e6354

Reviewer: convert-code-reviewer (independent pass, 2026-09-17). Spot-checked by the session: the stale docblock comment in ContextFeatureBucketingAttributesTest, the testRunFeaturesIgnoresExperienceKeysOrder method, the PR body's AgDR-0171/0172 citations, and the committed <<<<<<<< conflict markers in #98's AgDR-0182/0183/0184 — all verified on disk.

VERDICT: APPROVED (four IMPORTANT, none blocking)

Summary

Reviewed git diff origin/main...HEAD (merge base 7e96158, head c4e6354): 6 files, +848/-26, against SPEC-per-call-bucketing-attributes (SPEC.md + bucketing-attributes.md) and AgDR-0182.

The code change is small and correct. Context::runFeature / Context::runFeatures now use byte-for-byte the same idiom as Context::runExperience (get_object_vars spread → visitorProperties merge → environment fallback → preview suppressPersistence override applied last), and runFeatures passes ['experiences' => $attributes?->getExperienceKeys()] as FeatureManager::runFeatures' third argument. All four CAPs land (CAP-4 minus the wiki row, correctly — wikis are refreshed by the drift routine). No engine file is touched; the parity suite is green. Zero CRITICAL findings.

Shared semantics verified in code and tests:

  • enableTracking suppresses the wire event onlyDataManager::_retrieveBucketing gates putData on !$suppressPersistence and the enqueue on $enableTracking && !$suppressPersistence; the variation is chosen before either gate. ContextFeatureTrackingSuppressionTest asserts enqueueCalls === 0 with setCalls > 0 on both entry points.
  • experienceKeys: [] = no filterFeatureManager::runFeatures guards on !empty($filter['experiences']), so [] and null both fall to getEntitiesList. Data-provider row covers it.
  • Unknown key skippedDataManager::getItemsByKeys iterates the config list with in_array(..., true); unknown keys never match, nothing raises. Rows "one unknown among known" and "every key unknown" cover it and fail on main.
  • Key order ignored / config order — same getItemsByKeys loop; measured at the FeatureManager boundary: both key orders yield [-2, -2, -3, null]. See IMPORTANT-1 for the test gap.
  • Bucketing contract untouched — no diff in packages/Bucketing, packages/Data; --testsuite cross-sdk 474 tests / 651 assertions OK.
  • Preview zero-trace (CAP-3) — override applied after the spread on all four entry points; ContextPreviewTest::previewContextLeavesZeroTraceAcrossFeatureMethodsEvenWhenCallerAsksToBeTracked has explicit "not trivially passing" guards and fails when the override order is reversed.

FeatureManagerInterface change is docblock-only; the native signature runFeatures(string, BucketingAttributes, ?array $filter = null) is unchanged, so no runtime BC break. A probe implementor carrying the old array<string, string[]>|null docblock compiled against the new interface under phpstan.neon (level 6) with no errors.

PHP pitfalls: none introduced. ?? $this->environment matches the experience path; in_array uses are strict; no empty() on "0"-shaped values; isset($filter['features']) correctly keeps the disabled-feature extension gated on the absence of a features key.

CRITICAL

None.

IMPORTANT

1. The order test cannot observe evaluation order (confidence 90%)

  • File: packages/Php-sdk/tests/ContextFeatureBucketingAttributesTest.phptestRunFeaturesIgnoresExperienceKeysOrder
  • Rule: tests must exercise the behaviour claimed — bucketing-attributes.md's experienceKeys row states "order is ignored — evaluation follows config order", load-bearing for runFeature first-enabled precedence.
  • Evidence: mutating FeatureManager::runFeatures to evaluate in request order (array_map(getEntity) over $filter['experiences']) leaves all six CAP-2 tests passing. featuresByKey() collapses to a status map, and PHPUnit assertEquals is key-order-insensitive on associative arrays. BucketedFeature carries no experience identity, so the property is not observable at the Context surface at all.
  • Fix: assert at the FeatureManager::runFeatures boundary, whose raw arrays carry experienceKey: for keys [-3, -2] and [-2, -3] on the same visitor, assertSame(array_column($result, 'experienceKey'), ...) is identical for both orders and starts with test-experience-ab-fullstack-2 (config-first). A runFeature variant (feature-1 is carried by both -2 and -3) pins the precedence claim directly.

2. Undisclosed third behaviour change: loosely-typed values now throw instead of being ignored (confidence 80%)

  • File: packages/Php-sdk/src/Context.php (the spread in runFeature / runFeatures) → packages/Data/src/DataManager.php _retrieveBucketing(?string $forceVariationId, bool $enableTracking, …) under strict_types=1
  • Rule: SPEC constraint "the one behaviour change for existing callers ships visibly"; PR body lists two.
  • Evidence (measured, head vs base): runFeature('feature-1', new BucketingAttributes([... 'forceVariationId' => 100299456])) → main: BucketedFeature; head: TypeError: Argument #5 ($forceVariationId) must be of type ?string, int given. Same for 'enableTracking' => 'false' (Argument #6). runExperience already throws on main, so this is consistent, and typing the DTO is a spec non-goal — no code change required. But variation ids are numeric and the constructor-array idiom coerces nothing, so a feature-path caller passing an int today (silently a no-op) gets a fatal after upgrading.
  • Fix: add it to the PR body's "behaviour changes" section and the fix: release note; optionally state string for forceVariationId in the four CAP-4 docblocks.

3. Stale comment contradicts this PR's own docblock fix (confidence 90%)

  • File: packages/Php-sdk/tests/ContextFeatureBucketingAttributesTest.php — comment above the assertSame on $capturedFilter['experiences']
  • Evidence: comment says "FeatureManagerInterface's own docblock says 'experienceKeys'"; commit daf9f7e in this PR changed that docblock to ['experiences' => ['exp-key']].
  • Fix: delete the two-line comment.

4. PR body cites the wrong decision records; the real ones carry committed conflict markers in spec PR #98 (confidence 95%)

  • File: PR #63 body "Decision records"; ai-driven-product-dev@2563bfa (PR #98 head) docs/agdr/AgDR-0182-…php-feature-path.md frontmatter, also AgDR-0183-…, AgDR-0184-…
  • Evidence: body names AgDR-0171/0172; in #98's tree those are same-request-window-supersession and created-pair-undo-shape-vs-replay-identity. This PR's decisions are AgDR-0182 and AgDR-0183 there, and 0182/0183/0184 have <<<<<<<< HEAD … id: AgDR-0187 / ======== / id: AgDR-0182 >>>>>>>> committed in their YAML frontmatter (git show HEAD: confirms it is committed).
  • Fix: here, update the body to AgDR-0182/0183; in #98, resolve the three conflict blocks before merge (frontmatter with conflict markers will not parse as an AgDR).

Below threshold: the ~30-line rig builder is duplicated between ContextFeatureTrackingSuppressionTest::buildContext and ContextFeatureBucketingAttributesTest::buildSuppressionRig (php-sdk has no Sonar gate per D-7); fn (BucketedFeature $f) in the new preview test; the PR says twelve new cases fail on main — count is 15 rows across 14 methods (more RED than claimed).

What I checked

Scratch copies (review clone untouched — git status clean, HEAD c4e6354):

  • composer install in a clone of the PR head → OK.
  • vendor/bin/phpunit on the two new test files → OK (23 tests, 81 assertions); ContextPreviewTest.phpOK (15 tests, 77).
  • composer test (full) → OK, 1198 tests, 4552 assertions, 34 skipped (skips are the CONVERT_STAGING_SDK_KEY live cases).
  • vendor/bin/phpunit --testsuite cross-sdkOK (474 tests, 651 assertions).
  • composer analyze → PHPStan level 6 No errors; composer cs-check0 of 133 files.
  • RED replay: second clone at merge base 7e96158 (src untouched) + the PR's four test files → 15 failures / 38 tests; the preview CAP-3 guard passes on main, as the PR discloses.
  • Mutation: preview override moved before the spread → both preview zero-trace tests fail. Engine evaluated in request order → CAP-2 tests all still pass (IMPORTANT-1).
  • Probe scripts for loosely-typed inputs at head vs base (IMPORTANT-2) and for FeatureManager raw output order.
  • PHPStan probe of a legacy-docblock FeatureManagerInterface implementor → no errors.
  • gh pr view 63 --repo convertcom/php-sdk and gh pr view 98 --repo convertcom/ai-driven-product-dev (head 2563bfa == worktree HEAD).
  • Read: Context.php (all four entry points, mapToBucketedFeatureDto), FeatureManager::runFeature/runFeatures, DataManager::_getBucketingByField/_retrieveBucketing/matchRulesByField/getItemsByKeys, BucketingAttributes DTO, FeatureManagerInterface, phpstan.neon, qa.yml, test-config.json fixture topology, SPEC.md, bucketing-attributes.md, AgDR-0182.
  • No source files edited anywhere outside the scratchpad; no background processes left running.

@JosephSamirL JosephSamirL 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.

Approved via /convert:approve. An independent code review ran through /convert:review, and this issues the B-G4 human marker at c4e6354.

@abbaseya
abbaseya merged commit 096c5f4 into main Sep 17, 2026
11 checks passed
@abbaseya
abbaseya deleted the fix/per-call-bucketing-attributes branch September 17, 2026 14:17
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