feat: store canonical ticket URLs, assemble affiliate wrapper at resolve time - #828
Conversation
Homeboy Results —
|
…lve time (#818) The Ticketmaster Discovery API returns a fully-assembled Impact Radius affiliate wrapper. The import handler previously stored it verbatim into the Event Details block's `ticketUrl` attribute, freezing the affiliate ID, campaign ID, and ad ID into ~74,600 rows of post_content. Rotating any of them was a mass content migration. - inc/Core/ticket-destination.php: single filterable config (data_machine_events_ticket_wrapper_config()) describing the wrapper template + IDs; data_machine_events_assemble_affiliate_wrapper() assembles it at resolve time from a canonical URL; data_machine_events_normalize_ticket_url_entities() is the shared entity-decode helper moved out of ResolveTicketDestinationAbilities; data_machine_events_is_gated_ticket_url() is the new compliance-gate predicate covering both stored shapes. - Ticketmaster.php import handler now stores the canonical vendor URL (datamachine_unwrap_affiliate_url() on the Discovery API's url field), falling back to storing the wrapper verbatim when unwrapping fails (e.g. the known mangled v0.8.39-era rows). - ResolveTicketDestinationAbilities, DisplayVars, Calendar REST controller, and Event Details render.php all gate on data_machine_events_is_gated_ticket_url() instead of the narrower data_machine_events_is_affiliate_ticket_url(), so a canonical-stored monetized row keeps rendering the JS-gated button (#816 compliance) instead of silently demoting to a plain href. - New TicketUrlCanonicalBackfillAbilities/-Command: dry-run-by-default, --future-only, --limit backfill that converts wrapper-stored rows to canonical only when unwrap -> re-assemble round-trips byte-identical to the normalized stored wrapper (skipped_roundtrip_mismatch and mangled rows are reported, never written). Sibling to TicketUrlResyncAbilities/-Command, not a modification of either. - apply_to_hosts in the wrapper config includes both ticketmaster.com and ticketweb.com (Ticketmaster's own sub-brand) — verified against a random 800-row sample of the live wrapped corpus that ticketweb.com is ~33% of the wrapped corpus and round-trips byte-identically once included; a long tail of one-off third-party box-office hosts (axs.com, evenue.net, etix.com, etc.) is deliberately left out and stays wrapper-stored via the round-trip guard. Verification so far (see PR body for the full status and what is still outstanding in this session): - Read-only production queries (blog 7) confirm the 74k-row scale, the ~54% HTML-entity-encoded shape, and the exact affiliate/campaign/ad IDs baked into the default config. - A standalone round-trip harness against 800 random real stored wrapper values confirms byte-identical re-assembly for ticketmaster.com/ticketweb.com destinations and correct exclusion of the third-party long tail. - Full local PHPUnit run (native bootstrap, not the WP Codebox managed sandbox) shows no new failures/errors versus a true git-stash -u baseline off origin/main; the one TicketmasterHandlerTest failure present is pre-existing on main. AI-authored change (Extra Chill Bot via Kimaki session).
…kfill write PHPStan (level 7) could no longer prove `$blocks[$block_index]` still matched the shape `serialize_blocks()` requires after `$blocks[$block_index]['attrs'] = array_merge(...)` mutated a single offset of a variable-indexed array-shape union (parse_blocks()'s return type). Rebuilding the block array with all five keys (blockName, attrs, innerBlocks, innerHTML, innerContent) explicit gives the analyzer accurate type information instead of narrowing away the sibling keys. Same runtime behavior — 47/47 tests in AffiliateWrapperAssemblyTest and TicketUrlCanonicalBackfillAbilitiesTest still pass unchanged. `homeboy review lint data-machine-events --changed-since origin/main` now reports zero findings across phpcs, eslint, and phpstan.
homeboy review audit flagged an intra-method-duplication finding in registerAbility(): the `changes` and `report` output_schema item shapes both started with an identical 6-line post_id/title properties block before diverging into old/new vs reason/url. Extracted the shared post_id/title schema into a local variable and array_merge()'d the divergent fields into each item schema instead of repeating the literal block twice. No behavior change — output_schema shape is identical, just built from one shared piece instead of two copies. 47/47 tests in AffiliateWrapperAssemblyTest and TicketUrlCanonicalBackfillAbilitiesTest still pass; `homeboy review lint` still reports zero findings.
44ac12b to
c769883
Compare
…emas
The previous fix only deduped the post_id/title VALUES shared between
the `changes` and `report` output_schema item shapes, but left the
surrounding array(type=>array, items=>array(type=>object,
properties=>array_merge(...))) wrapper duplicated verbatim across both
blocks — homeboy review audit correctly flagged the same
intra-method-duplication finding again (6 identical lines), just
shifted a few lines down.
Fixed properly this time: extracted a `buildPostKeyedItemSchema()`
helper that builds the full item shape (post_id + title + caller-
supplied trailing fields) once. `changes` and `report` each call it
with their own trailing fields (old/new vs reason/url) and wrap the
result in `array('type' => 'array', 'items' => $schema)` — no literal
block is repeated at all now, not just no duplicate values.
47/47 tests in AffiliateWrapperAssemblyTest and
TicketUrlCanonicalBackfillAbilitiesTest still pass unchanged (output
schema shape is identical). homeboy review lint --changed-since
origin/main unaffected (zero findings). Local homeboy review audit did
not complete in this sandbox session (times out on full-repo
fingerprinting, unrelated to this change) — verified the specific
finding is structurally resolved by inspection instead; CI's audit
gate will be the final confirmation.
All three gates green — mergingaudit pass, lint pass, test pass (run 34551383324). Rebase verificationI checked the conflict resolution rather than trusting it, since this branch and #826 both rewrote
You correctly consumed #826's extracted helper instead of restoring an inline copy. That was the specific thing I was worried about in the conflict. The important part
Gating on "routes through the first-party redirect" instead of "is stored as an affiliate URL" is the correct invariant, and it is what makes the migration safe. Design that held upThe byte-identity gate is the right call: only convert a row when Follow-up correctly filed and linked rather than fixed in-scope: #824 (the That is now three tracked instances of the same pattern — #821, #824, and the |
Problem
The Ticketmaster Discovery API returns a fully-assembled Impact Radius affiliate wrapper (our API key is affiliate-linked).
Ticketmaster.phpstored it verbatim into the Event Details block'sticketUrlattribute, freezing the affiliate ID, campaign ID, and ad ID into ~74,600 rows ofpost_content(74,643 published events with anevyy.netURL, verified by read-only query against blog 7 as of this PR). Rotating any of those IDs — affiliate ID reissue, network migration, a tracking-param change — was a mass content migration rather than a config edit.The fix
Ticketmaster.php) now stores the canonical vendor URL.inc/Core/ticket-destination.phpadds one filterable config (data_machine_events_ticket_wrapper_config()— affiliate ID / campaign ID / ad ID / template /apply_to_hosts) and assembles the wrapper from it at resolve time (data_machine_events_assemble_affiliate_wrapper()).ResolveTicketDestinationAbilities(and every compliance-gated render/REST surface) handles wrapper-stored rows (return as-is) and canonical-stored rows (assemble) identically — the backfill is therefore non-blocking and independently revertible.TicketUrlCanonicalBackfillAbilities/TicketUrlCanonicalBackfillCommand— a sibling ofTicketUrlResyncAbilities/-Command(not a modification of either) — converts wrapper-stored rows to canonical, dry-run by default.skipped_roundtrip_mismatch(ormangled_unrecoverablefor the known v0.8.39-era mangled shape) and left wrapper-stored. A partial, provably-safe migration beats a complete, possibly-lossy one.Proof: rotating the affiliate ID is a config edit, zero content writes
AffiliateWrapperAssemblyTest::test_rotating_affiliate_id_changes_output_without_touching_contentandTicketUrlCanonicalBackfillAbilitiesTest::test_affiliate_id_rotation_is_config_onlyadd a filter ondata_machine_events_ticket_wrapper_config, re-resolve the same event, assert the wrapper reflects the new ID, and assertpost_contentis byte-identical before/after. Nowp_update_postcall anywhere in the rotation path.Byte-identity evidence
AffiliateWrapperAssemblyTest::test_reassembly_from_real_stored_values_is_byte_identicalruns the fullnormalize → unwrap → assemblepipeline against real stored production wrapper values (fetched via read-only query against blog 7) across all three observed stored shapes (raw&, HTML-entity&— ~54% of rows, verified: 40,560 / 74,643 — and the legacy\u0026amp;combined artifact) and asserts byte-for-byte equality with the original.I additionally ran a standalone, non-test verification harness (not part of the PHPUnit suite — a throwaway script, described in "Verification status" below) against 800 random real production rows, sampled fresh via read-only
wp db query. Results:ticketmaster.comandticketweb.comdestination in the sample.axs.com,evenue.net,etix.com,eventim.us,dice.fm,seatgeek.com,universe.com,tixr.com,prekindle.com,fgtix.com) — 1-4 rows each — which correctly fail the round-trip guard because they're not inapply_to_hostsand stay wrapper-stored. Zero silent corruption anywhere in the sample.A real gap found and fixed during verification:
ticketweb.comThe original
apply_to_hostslist shipped with onlyticketmaster.com, on the assumption (stated in a code comment) that it was "the only vendor whose URLs arrive affiliate-wrapped via the Discovery API today." That assumption was wrong. A read-only production query showedticketweb.com— Ticketmaster's own sub-brand for smaller venues — is ~33% of the entire wrapped corpus (24,620 of 74,643 published rows). Under the original list, every one of those rows would have permanently failed the round-trip guard (not because of an encoding problem — the guard's own safety net caught this correctly — but purely because the destination host wasn't in the allowlist) and stayed wrapper-stored forever, defeating a third of the migration's purpose.Fixed:
apply_to_hostsnow includes bothticketmaster.comandticketweb.com. Verified against the 800-row sample: round-trip match rate went from 524/798 (66%) to 779/799 (97.5%) onceticketweb.comwas added, with the remaining ~2.5% being the genuine long-tail third-party hosts described above, which are deliberately not added (an unbounded, ever-growing per-venue host allowlist is not "vendors we actually monetize," it's guessing at an open set). New test coverage:test_ticketweb_reassembly_is_byte_identical(including the?REFERRAL_ID=tmfeed-bearing shape) andtest_third_party_box_office_hosts_are_not_wrapped.Regression check: #820 Ticketmaster-compliance guarantees
Before touching anything, I diffed the working tree's
render.phpandResolveTicketDestinationAbilities.phpagainstorigin/main(which already has #820 merged) to confirm the prior agent session hadn't weakened them. Result: no regression.hrefgating on affiliate buttons — intact.render.phpstill emitsdata-ticket-ref, no destination URL in markup,<a ... data-ticket-ref="..." role="button" ...>with nohrefattribute for gated tickets. The only change is swapping the gating predicate fromdata_machine_events_is_affiliate_ticket_url()to the new, broaderdata_machine_events_is_gated_ticket_url()— needed so a canonical-stored monetized row (post-backfill) still gates instead of silently demoting to a directhref.normalizeResolvedUrl()entity decoding (&and\u0026) — intact and generalized: the logic moved verbatim into the shareddata_machine_events_normalize_ticket_url_entities()inticket-destination.phpso the resolve ability, the backfill, and any future consumer share one implementation instead of private copies.<noscript>permalink fallback — untouched, still present at the same location inrender.php.DisplayVars, the Calendar REST controller (Calendar.php), andrender.phpall got the same one-line predicate swap (is_affiliate_ticket_url→is_gated_ticket_url), each with an inline comment explaining why.How the read path stays tolerant of both shapes, and why the backfill is revertible
ResolveTicketDestinationAbilities::resolveTicketUrl():data_machine_events_is_affiliate_ticket_url()is true → returned as-is (pre-Store canonical ticket URLs and assemble affiliate wrappers at resolve time #818 behavior, unchanged).data_machine_events_assemble_affiliate_wrapper()re-assembles the wrapper from config.Both stored shapes resolve to the identical wire destination, so: the backfill can run partially, be interrupted, resumed, or reverted (a converted row can simply be left canonical — both shapes resolve identically — or the wrapper can be restored from the backfill's own before/after change log) without breaking any consumer mid-migration.
Existing helpers reused (nothing reimplemented)
datamachine_unwrap_affiliate_url()(event-dates-sync.php) — extraction, unchanged.data_machine_events_is_affiliate_ticket_url()/data_machine_events_affiliate_ticket_hosts()(affiliate-links.php) — wrapper-shape detection, unchanged.ResolveTicketDestinationAbilities::normalizeResolvedUrl()'s entity-decoding logic — moved verbatim (not rewritten) into the new shareddata_machine_events_normalize_ticket_url_entities().EventDateQueryAbilities::executeQueryEvents()— the backfill's event traversal, same query surfaceTicketUrlResyncAbilitiesuses (scope/status/per_page/order), not a new query path.Backfill usage
Dry-run is the default (
dry_rundefaults totruein both the ability input schema and the CLI). Recommended rollout: dry-run first, then--future-only --executeover the ~24,941 live, revenue-bearing events (future-dated as of issue filing), then the archival tail in slower--limit-chunked passes. Idempotent/resumable: a converted row is no longer wrapper-shaped, so re-running (or resuming after an interruption) reports itskipped_not_wrapperand moves on.#816 compliance guarantee confirmed intact
The affiliate ID appears in zero rendered HTML and zero JS bundles, before and after this change:
git diff --stat— only PHP and test files changed).render.php's gated branch still emits nohrefat all for a gated ticket (data-ticket-refonly); the affiliate wrapper (or the config that would assemble it) never reaches the HTML response.resolve-ticket-destinationability (which does carry the assembled wrapper) hasshow_in_rest: falseand is deliberately not the generic REST-visible ability surface — it backs the first-party redirect endpoint server-side only.Follow-up issue:
datamachine_unwrap_affiliate_url()double-decodeDesign ruling from this task:
datamachine_unwrap_affiliate_url()double-decodes (parse_str+urldecode), so wrappers with nested percent-encoding (the SeatGeek shape,dd_referrer=https%253A%252F%252F...) don't round-trip. That's out of scope here — its output feeds duplicate-detection identity, and changing decode behavior could silently alter dedup matching across the corpus. I initially filed this as #827, but it turned out to duplicate #824, filed earlier by a prior session on this same task before a respawn. #827 is now closed and consolidated into #824 — that's the tracking issue this PR links to.Merge-order dependency: #826 — rebased and resolved
Sibling PR #826 (issue #823, corrupted-redirect detector) merged to
main(squash0308076) while this PR was in flight, also touchinginc/Abilities/ResolveTicketDestinationAbilities.php. It extracted the\u0026+wp_specialchars_decode()pair out ofnormalizeResolvedUrl()into a new shared helper,datamachine_decode_stored_url_artifacts()(event-dates-sync.php), so that logic isn't duplicated a third time.I rebased this branch onto post-#826
mainand resolved the conflict by consuming that helper rather than keeping this PR's own copy:data_machine_events_normalize_ticket_url_entities()entirely fromticket-destination.php(it was a verbatim duplicate of the same two-step decodedatamachine_decode_stored_url_artifacts()now owns).ResolveTicketDestinationAbilities::normalizeResolvedUrl()andTicketUrlCanonicalBackfillAbilities's entity-normalization step both now calldatamachine_decode_stored_url_artifacts()directly.grep -rn "datamachine_decode_stored_url_artifacts" inc/"confirms every consumer in this PR's diff calls the shared helper; there is no duplicatedwp_specialchars_decode+\u0026pair anywhere this PR touches.data_machine_events_is_gated_ticket_url()(the predicate swap that prevents silent revenue loss on migrated rows — see the note at the top of this PR) survived intact inrender.php,DisplayVars, andCalendar.php. The Ticketmaster compliance: JS-gate affiliate ticket links so they are not in raw HTML #816 compliance guards (data-ticket-refwith nohrefon the gated path, the<noscript>permalink fallback, entity normalization on bothresolveTicketUrl()return paths) are all still present and unchanged.origin/mainworktree (post-fix: detect and repair affiliate redirects with punctuation-stripped destinations (#823) #826) as the baseline and compared against this rebased branch: 1232 tests on the branch, 1177 on baseline (the +55 are this PR's and fix: detect and repair affiliate redirects with punctuation-stripped destinations (#823) #826's own new tests), identical set of 62 pre-existing failing test names on both sides — zero new failures.homeboy review lint --changed-since origin/main: zero findings.Verification status
Confirmed:
events.extrachill.com) verify: 74,643 published events with anevyy.netticketUrl; 40,560 (54.35%) HTML-entity-encoded; the exact affiliate ID (1191134) / campaign ID (264167) / ad ID (4272) baked into real stored rows match the default config exactly;ticketweb.comis 24,620 of those 74,643 rows.wp db query+wp eval-fileonly).homeboy review test, GitHub Actions, managed WP Codebox sandbox): passing — confirmed on the commit before the final rebase; will re-confirm on the rebased HEAD once this update's CI run completes.homeboy review lint): passing — zero findings across phpcs, eslint, phpstan (level 7).homeboy review audit): initially failed with one genuine finding — an intra-method-duplication in the new backfill ability'sregisterAbility()(thechanges/reportoutput_schema item shapes shared an identical 6-linepost_id/titleprefix before diverging). Fixed by extracting the shared prefix into a local variable andarray_merge()-ing the divergent fields, rather than repeating the literal block. Re-verified locally withhomeboy review lint --changed-since origin/main(unaffected — zero findings) and the affected tests (47/47 pass); the fix is pushed and awaiting this update's CI run.ee52bd5, once post-rebase against origin/main at0308076(post-fix: detect and repair affiliate redirects with punctuation-stripped destinations (#823) #826) — with a matching from-scratch baseline built in an isolated git worktree both times: zero new failing/erroring test names in either comparison (diffof the sorted failure-name lists is empty both times). Post-rebase: 1232 tests on the branch vs 1177 on baseline (the +55 are this PR's and fix: detect and repair affiliate redirects with punctuation-stripped destinations (#823) #826's own new tests), identical 62 pre-existing failure names on both sides.php -lclean on every touched file.git diff --statconfirms PHP + test files only).datamachine_decode_stored_url_artifacts()— see the dedicated section above for detail and verification.All three CI gates are green on the current HEAD (
homeboy review test,homeboy review lint,homeboy review audit— run). The audit gate required two follow-up commits after the initial submission:registerAbility()— thechanges/reportoutput_schema item shapes shared an identical 6-linepost_id/titleprefix. First fix only deduped the shared values, leaving the surrounding array-wrapper structure still duplicated; audit caught that too, on the next run. Second fix extracted a realbuildPostKeyedItemSchema()helper so no literal block is repeated at all (not just no duplicate values) — that's the one CI is now green against.Marking ready for review.
AI disclosure
Authored by Extra Chill Bot (AI agent) via Kimaki session.
Closes #818