Multi-Brand Isolation Implementation Plan
Status
Done — Phases 1–15 delivered on main (see ADR-009 "Implementation"
section for the per-phase commit list).
In-repo Phase 16 closure (2026-05-06). The servers_v2/docker-compose.yml
defaults that controlled the hard-flip are now production-grade out of the
box:
MULTI_BRAND_ENFORCEMENTdefaults toenforce(wasobserve).WALLET_BRAND_SIGNATURE_REQUIREdefaults toon(wasoff).PER_CALLER_TOKEN_REQUIREDdefaults toon(wasoff).VERIFY_CALLBACKSalready defaulted totrue.
Companion fail-closed code paths are merged:
- Boot guards in
wallet_service,game_servicerefuse to start when the enforce-mode combination would silently degrade (assert_brand_signing_key_configured,assert_verify_callbacks_safe_for_runtime). - Consumer fail-closed for missing envelope
brand_idregardless of mode (rolling, promotion). - Producer-side schema-required
brand_idon everyDomainEventenvelope. - Phase 4E session dual-read fallback removed (gateway and player_service).
- Wallet write entry points fail-loud on missing
X-Brand-Id— everybucket_wallet.pyv2 route, every brand-scopedwallet.pylegacy route, everytopology.pytopology/policy CRUD route, and thequeries.pytransfer_withdrawallegacy alias all envelope-reject upfront with_missing_brand_envelope(or thetopology.pyequivalent) instead of lettingrequest_brand_id=Nonepropagate down. Thebrand_signature_verifieritself fail-closes in enforce mode whenbrand_id is Noneas defence-in-depth, so a route-layer regression cannot bypass signature verification. - Settlement / rollback events derive
brand_idfrom the authorization row when the caller does not forward it. Thewallet_bet_authorizationSELECT now returnsbrand_idand_write_topology_bet_settled_event/_write_topology_bet_rollback_eventfall back to that value, so theDomainEventenvelope can never publish without a numericbrand_id(Pydantic enforces, but the fallback prevents a single missing route forwarding from crashing settlement). - Topology / policy store fallback retained as defence-in-depth. The
wallet_topology_store._resolve_brand_idretains its default-brand resolution path for the case a future internal helper accidentally reaches the store withbrand_id=None; thewallet_topology_default_brand_fallback_totalcounter exists to verify the fallback never fires in production soak. With every public route now envelope-rejecting upfront, that counter must stay at zero.
Operator-side Phase 16 (production rollout). The remaining checkboxes
in the "Phase 16" section below are deployment-time soak/sequencing
activities, not code gaps. They are tracked by
docs/runbooks/multi-brand/phase-16-hard-flip-checklist.md and run
when the platform is promoted from staging to production. Pre-deploy
the in-repo posture is already final.
Companion Plans
Two follow-up plans landed alongside this one and are also marked done:
2026-05-05-event-brand-scoping.md— schema-requiredbrand_idon every domain event; consumers fail-closed regardless of mode.2026-05-05-phase-4e-dual-read-sunset.md— removal of the legacyuser-session-{account}Redis key plus the gateway / player_service dual-read fallback.
Date
2026-04-28
Owners
- Platform Backend
- Player Domain
- Wallet Domain
- Agent Domain
Affected Services
gatewayplayer_servicewallet_servicerolling_servicepromotion_servicegame_serviceagent_serviceadmin_servicerecon_service
Related ADRs
docs/adr/ADR-009-multi-brand-domain-routed-isolation.mddocs/adr/ADR-005-wallet-topology-bucket-ledger-model.md
Related Spec
docs/specs/multi-brand/2026-04-27-multi-brand-isolation-spec.md
Related Runbooks
docs/runbooks/multi-brand/multi-brand-isolation-rollout.md
Goal
Implement domain-routed, single-database, brand-scoped multi-brand isolation
across servers_v2 per ADR-009 and the related spec, with a backfilled
default brand so the existing single-brand environment continues to operate
through every step of the migration.
Success Signals
- Two brands can run simultaneously on the same
servers_v2runtime with fully independent player identity, wallet state, rolling, settlement, and configuration. gatewayresolves brand from the request domain; JWT and request brand must match.wallet_servicerejects every cross-brand command with a stable error and a counter increment.- Game provider integrations work for two brands using namespaced outbound accounts and reverse-parsed inbound callbacks.
- Every existing test suite continues to pass; new brand-aware suites cover every command, query, and event family.
- Local Docker end-to-end runs cover two brands at the same time without cross-brand bleed.
- Removed staff routes return
404; no other route depends on staff identity.
Preconditions
- Spec and ADR-009 are accepted.
- Wallet topology / policy work in ADR-005 is in its current state and not in mid-flight refactor.
- Local Docker stack is green on
main. - Product confirms the brand catalog seed: at least
defaultfor backfill, plus the second brand the rollout will validate. - Product confirms
brand_codefor each seeded brand and confirms that the chosenbrand_codecharacters are accepted by every game provider's account format.
Implementation Target Map
Primary code areas to add or rewrite:
servers_v2/shared/contracts/src/rgb_contracts/-- brand DTO,X-Brand-Idheader constant, brand context helpers, brand-aware event payload schemasservers_v2/shared/rgb_db/src/rgb_db/models/-- brand and brand_config models;brand_idcolumns on every brand-scoped model; brand-aware unique constraintsservers_v2/admin_service/app/api/routes/brand.py(new) -- brand catalog CRUDservers_v2/admin_service/app/api/routes/brand_config.py(new) -- per-brand configuration CRUDservers_v2/admin_service/app/api/routes/agent_brand.py(new) -- agent-to-brand allow-list CRUDservers_v2/admin_service/app/api/routes/legacy_admin_v2.py-- removeservers_v2/admin_service/app/api/routes/legacy_auth.py-- removeservers_v2/admin_service/app/tasks/tag.py-- replaceprojectswitch withbrand_configresolutionservers_v2/gateway/app/api/routes/player_routes.py-- brand resolution andX-Brand-Idpropagationservers_v2/gateway/app/services/proxy.py-- forwardX-Brand-Idservers_v2/gateway/app/middleware/-- JWT-vs-domain brand checkservers_v2/player_service/app/services/domain_cache.py-- brand-bearing cache values fordomain:agent:*anddomain:level:*servers_v2/player_service/app/api/routes/common.py,servers_v2/player_service/app/api/routes/player.py-- brand-aware registration and lookupsservers_v2/player_service/app/api/helpers.py-- brand resolution helpers; preserve error code 54 semanticsservers_v2/wallet_service/app/services/wallet_topology_store.py-- per-brand topology and policy resolutionservers_v2/wallet_service/app/services/wallet_bucket_commands.py-- cross-brand command rejectionservers_v2/wallet_service/app/api/routes/topology.py,servers_v2/wallet_service/app/api/routes/wallet.py,servers_v2/wallet_service/app/api/routes/bucket_wallet.py,servers_v2/wallet_service/app/api/routes/queries.py--X-Brand-Idenforcementservers_v2/rolling_service/app/services/rolling_ops.py,servers_v2/rolling_service/app/services/event_consumer.py-- brand-scoped progress and consumptionservers_v2/promotion_service/app/api/routes/coupon.py,servers_v2/promotion_service/app/tasks/settlement.py-- brand-scoped coupon, settlement, sagaservers_v2/game_service/-- outbound account namespacing helper; inbound callback reverse-parse for every provider under/HO/*,/mg/*,/wc/*,/bti/*,/splus/*,/bt1/*,/digitain/*,/integration/*servers_v2/agent_service/app/api/--agent_brandenforcement at the agent edge; per-brand agent settings and domainsservers_v2/recon_service/-- brand_id onshooter_*rows; brand resolved from matched deposit- alembic migrations under the centralized shared migrations
directory
servers_v2/shared/rgb_db/migrations/versions/(latest existing is0021_wallet_bet_settlement_metadata.py). All multi-brand migrations are sequenced from0022_*upward in this single directory; there are no per-service migration directories in this repo. - backfill verification script under
servers_v2/tools/multi_brand_backfill/(new)
Primary tests to add or rewrite:
- contract tests for brand DTO and
X-Brand-Idpropagation - migration and backfill tests
gatewaybrand resolution and JWT mismatch testsplayer_servicecross-brand registration and recovery testsagent_serviceallow-list enforcement testswallet_servicecross-brand command rejection tests for every commandwallet_serviceper-brand topology / policy resolution testsrolling_servicebrand-scoped consumption testspromotion_serviceper-brand resolution testsgame_serviceoutbound namespacing and inbound reverse-parse tests per providerrecon_servicebrand-scoped match and approval testsadmin_servicebrand catalog,brand_config,agent_brandCRUD testsadmin_servicestaff removal tests- observability tests for log fields and metric labels
- local Docker two-brand end-to-end flows
Tasks
Phase 1 -- Shared Contracts And Models
Delivered by: 53639f23, 1eca859c, bc258a05
- Add a
brandSQLAlchemy model withbrand_id(PK),brand_code(unique, immutable, charset-constrained),name,default_currency,status,created_at,updated_at. - Add a
brand_configmodel with(brand_id, key)PK and a JSONvaluecolumn. - Add an
agent_brandmodel with(agent_id, brand_id)PK,status, andcreated_at. - Add a shared
BrandContextDTO and a sharedX-Brand-Idheader constant inrgb_contracts. - Add a shared
X-Brand-Signatureheader constant and a sharedsign_brand_assertion(caller_service, brand_id, request_id, timestamp, key)helper inrgb_contracts(HMAC-SHA256). Add a sharedverify_brand_assertion(...)helper. Both consume theBRAND_SIGNING_KEYenv var. - Add a shared internal-token enum
InternalCallerServiceinrgb_contractslisting every legitimate caller (gateway,agent,admin,recon,game,promotion,rolling). Consumer-service middleware reads the relevantINTERNAL_SERVICE_TOKEN_<CALLER>env vars and rejects unknown or mismatched tokens. Document the token-rotation procedure in the runbook. - Add a
brand_id: intfield to the sharedDomainEventenvelope inservers_v2/shared/contracts/src/rgb_contracts/events/base.py. Every outbox event automatically gainsbrand_idthrough the envelope; per- payload schemas (e.g.events/wallet.py) are not modified individually. Producers populatebrand_idfrom the owning row; consumers readbrand_idfrom the envelope and persist it into the consumer-side projection. - Add a shared brand resolver helper that reads
X-Brand-Idfrom a FastAPI request and returns a typedBrandContext; reject missing header for brand-scoped operations. - Write contract tests for the brand DTO, the header constant, the
resolver, and the
DomainEventenvelope's newbrand_idfield. - Run shared contract tests.
- Commit Phase 1.
Phase 2 -- Migrations And Default Brand Backfill
Delivered by: 304deec6, d01f85a9, c5d39d52, d7d0e134, 69c5d5a0
All migrations live in servers_v2/shared/rgb_db/migrations/versions/
(centralized). Sequence from 0022_* upward. No per-service migration
directories exist in this repo.
Production Postgres assumption: PostgreSQL 14+. Every migration in
this phase runs with lock_timeout = '5s' and statement_timeout = '30min' set per session; if lock_timeout fires the migration aborts
cleanly without queueing connections to the point of an outage. The
migrations below are designed to favor metadata-only changes
(ALTER TABLE ... ADD COLUMN nullable, CREATE INDEX CONCURRENTLY)
over full table rewrites. Per-table runtime estimates against a
prod-sized snapshot are recorded in
docs/runbooks/multi-brand/multi-brand-isolation-rollout.md Local
Docker Validation step before any environment beyond local is
migrated.
-
0022_create_brand_aggregates.py: createbrand,brand_config, andagent_brandtables.brand.brand_idisBIGINTautoincrement.brand.brand_codeisVARCHAR(16)withCHECK (brand_code ~ '^[a-z][a-z0-9]{1,15}$')(lowercase alphanumeric, leading letter, 2-16 chars). -
0023_seed_default_brand.py: insert onebrandrow withbrand_code='default',name='Default Brand', status enabled, plusbrand_configrows seeded with the values the system currently relies on globally (rolling ratios, cashback / rebate / lossback rates, payment channel selection). Also insert oneagent_brandrow per existingagentrow of the form(agent_id, default_brand_id, status='enabled')so every existing agent retains brand-1 access through Phase 11 enforcement; without this seed every agent's first request after Phase 16 hard-flip would be rejected by the allow-list check. -
0023b_install_agent_brand_autoseed_trigger.py: install a PostgresBEFORE INSERTtrigger onagentthat automatically inserts a correspondingagent_brand(NEW.agent_id, default_brand_id, 'enabled')row in the same transaction. The trigger references thedefault_brand_idpopulated by0023, so it must run after0023. This closes the seed-race window during Phases 2-12 whenadmin_servicedoes not yet writeagent_brandfor new agents. The trigger is removed by0028_remove_agent_brand_autoseed_trigger.pyafter Phase 12 ships the application-level write. -
0024a_add_brand_id_nullable.py: addbrand_id BIGINT NULLto every brand-scoped table enumerated in the## Brand-Scoped Table Inventorysection ofdocs/architecture/data-ownership.md. Nullable + no DEFAULT is metadata-only on PG 12+ and avoids full-table rewrites. Concurrent writes during this migration land withbrand_id = NULL(handled by0024bbelow). -
0024b_backfill_brand_id.py: chunked UPDATE backfill against a literaldefault_brand_idvalue resolved once at migration start (not via per-row subquery). Chunk size 10000 rows; commit between chunks; loop untilWHERE brand_id IS NULLreturns zero rows. This avoids a full-table rewrite under ACCESS EXCLUSIVE that the original single-statement DDL withDEFAULT (SELECT ...)would have caused. -
0024c_set_brand_id_not_null.py: tail-end UPDATE catches any rows landed during0024bwith NULL, thenALTER TABLE ... ALTER COLUMN brand_id SET NOT NULL. PG 12+ does this without a full rewrite when aCHECK (brand_id IS NOT NULL) NOT VALIDconstraint exists and has been validated; the migration adds the CHECKNOT VALID, validates it viaVALIDATE CONSTRAINT(non-blocking scan), then sets NOT NULL atomically and drops the redundant CHECK. - (Migration number
0025is intentionally skipped: an earlier draft used0025_drop_brand_id_default.pyto drop a server DEFAULT that the restructured0024ano longer sets. The number is reserved to preserve the historical ordering and to avoid renumbering downstream migrations.) -
0026_add_brand_id_fk.py: add a foreign key from every brand-scoped table'sbrand_idtobrand(brand_id)withON DELETE RESTRICT. UseNOT VALIDthenVALIDATE CONSTRAINTto avoid an exclusive lock long enough to firelock_timeout. -
0027_brand_scoped_uniqueness.py: change uniqueness as follows. Every new index usesCREATE UNIQUE INDEX CONCURRENTLY(non-blocking) before the corresponding old index is dropped, so there is no window where both the old constraint is gone and the new one is not yet enforcing. Wallet topology and policy CRUD MUST be paused (admin freeze) for the duration of this migration step; the runbook documents the freeze procedure and confirmation step.player: createCONCURRENTLY(brand_id, account)unique index, then drop the oldaccount-only constraintwallet_topology: createCONCURRENTLY(brand_id, code, version)unique index, drop olduq_wallet_topology_code_version; createCONCURRENTLYnew partial active index(brand_id) WHERE status='ACTIVE', drop olduq_wallet_topology_single_active. Activation is paused for the duration; ADR-005's "single ACTIVE per brand" invariant is preserved across the swap because the new partial index exists before the old one is dropped.wallet_policy: createCONCURRENTLY(brand_id, topology_code, version, policy_key)unique index, drop olduq_wallet_policy_version; createCONCURRENTLYnew partial active index(brand_id, topology_code, policy_key) WHERE status='ACTIVE', drop olduq_wallet_policy_active_key. Same pause-then-swap discipline aswallet_topology.wallet_bucket_type: replaceuq_wallet_bucket_type_topology_codewith(brand_id, topology_code, topology_version, code)uniquewallet_account:(brand_id, player_id)uniquewallet_bucket: existing constraint becomes(brand_id, player_id, topology_code, bucket_type_code)uniquewallet_bet_authorization: extenduq_wallet_bet_authorization_provider_betto(brand_id, provider_type, provider_id, bet_id);uq_wallet_bet_authorization_requestbecomes(brand_id, request_id)wallet_transfer:uq_wallet_transfer_requestbecomes(brand_id, request_id)wallet_idempotency: uniqueness becomes(brand_id, idempotency_key)(allows the same key to legitimately appear in two brands)- PK rewrites (drop existing PK, add composite PK after
0024csets NOT NULL). Each PK rewrite runs in its own transaction withlock_timeout = '5s'so a stuck lock aborts cleanly without queueing connections. Concurrent writes to the affected table are paused via the application-level "table freeze" mechanism (a Redis flag checked by every writer) for the duration of each rewrite; the freeze is documented in the runbook with rollback. Sequencing in0027body:i18nlast (most-read table; minimize its freeze window),agent_setting,player_wallet_limit,daebak_email,mg_account,wc_account,digitain_tokenfirst. Apply to every table in the### Single-Column PKs Requiring Rewrite In 0027table indocs/architecture/data-ownership.md:agent_setting->(agent_id, brand_id),player_wallet_limit->(brand_id, player_id),daebak_email->(brand_id, player_id),mg_account->(brand_id, account),wc_account->(brand_id, account),digitain_token->(brand_id, account),i18n->(brand_id, code). Every other brand-scoped table uses a surrogateguid BIGINTPK that remains untouched; only uniqueness constraints change agent_domain: keep existing surrogateguidPK; add a compositeUNIQUE(agent_id, brand_id, domain)constraint and keep the existingUNIQUE(domain)global constraint (so a domain still cannot appear twice in any combination). Do not drop the surrogate PK; FK references toagent_domain.guidwould break.- any other uniqueness change required by the spec
- Add a
servers_v2/tools/multi_brand_backfill/verification script that iterates every brand-scoped table and asserts zero rows with NULLbrand_id. Exit non-zero on any failure; wire into the deployment pipeline so a non-zero count blocks rollout. - Add a reversibility test that runs the migrations forward,
inserts seed rows under
brand_id=defaultandbrand_id=brand2, then runs the migrations backward; confirm reversal is either refused safely OR preserves enough information to re-run forward without data loss. (An empty-DB reversibility test alone is not sufficient; alembic always reverses pure DDL.) - Add a backfill smoke test that runs the migrations against a copy of the local Docker database and confirms row counts and uniqueness post-migration.
- Run all migration tests.
- Commit Phase 2.
Phase 3 -- Domain-To-Brand Map And Edge Resolution (Soft-Fail)
Delivered by: 8011f2d6, 030383d5, 586b6588
- Update
servers_v2/player_service/app/services/domain_cache.pyso thatdomain:agent:{host}anddomain:level:{host}resolve to a value carryingbrand_id(e.g. JSON withbrand_idandagent_id/level_id). - Add a one-time Redis migration helper that rewrites existing
domain:agent:*anddomain:level:*values to the new shape, binding every existing domain to thedefaultbrand. - Update
servers_v2/gateway/app/api/routes/player_routes.pyso that_extract_domainis followed by a brand lookup; attachrequest.state.brand_idandrequest.state.brand_code. - Preserve the existing
body["domain"] = _extract_domain(request)injection in legacy player routes (servers_v2/gateway/app/api/routes/player_routes.py:526and:573). The brand projection is layered on top; do not remove thedomainbody injection or any caller that depends on it. - Update
servers_v2/gateway/app/services/proxy.pyso every forwarded request carriesX-Brand-IdAND any inboundX-Brand-Idheader from the external client is stripped before injection (so external callers cannot spoof the brand).agent_serviceperforms the same strip on its edge. - Introduce a
MULTI_BRAND_ENFORCEMENTruntime flag with three modes:off,observe, andenforce. Default isobserve. - Add a
gatewaymiddleware that, on requests with a JWT, computesjwt.brand_id == request.state.brand_id. Inobservemode, log the comparison and incrementbrand_resolution_failed_total{reason="jwt_domain_mismatch",mode="observe"}but do not reject. Inenforcemode, reject mismatches with the documented error code. JWTs that lackbrand_idare treated the same way (logged inobserve, rejected inenforce). - Add
gatewaytests for: valid domain, unknown domain, observe-mode logging on JWT/domain mismatch, observe-mode logging on JWT missingbrand_id, and request body / query attempts to override brand. - Run
gatewaytests. - Confirm the deployment defaults
MULTI_BRAND_ENFORCEMENT=observeduring the rollout window; the 2026-05-06 in-repo closure flips the compose default toenforce. - Confirm scope: brand resolution attaches
request.state.brand_idvia agatewaymiddleware that fires before route dispatch, so it applies uniformly toplayer_routes.py,legacy_player_routes.py(which usesdispatch_to_path-style passthrough rather than body mutation), andprovider_routes.py.gateway/app/api/routes/admin_routes.pyandagent_routes.pyare not in the brand-resolution scope because back-office and agent traffic do not enter throughgateway(perdocs/architecture/http-entrypoints.md); confirm both files remain unchanged or are documented as dead code if no longer wired. - Commit Phase 3.
Phase 4 -- Player Service Brand Scoping
Delivered by: 288a5f18, 5730f74e, a410cbce
- Make
playerqueries and writes brand-aware inplayer_service/app/api/routes/player.pyandcommon.py. - Update registration so
brand_idis taken from the resolved request brand and is rejected when supplied by the client. - Wire
MAX_PLAYER_ACCOUNT_LEN = 32into the registration validator inservers_v2/player_service/app/api/routes/player.py(and any other registration entry point). Reject anyaccountlonger than 32 chars with the documented validation envelope. The constant is defined inrgb_contractsper Phase 1. - Implement brand-aware recovery flow per spec
### Recovery flow brand precedence. Recovery tokens issued after Phase 5 deploy carrybrand_idas a signed claim. Token-embeddedbrand_idwins over request domain; if the token lacksbrand_id(issued before Phase 5), fall back to request domain; if the resolved brand does not match the underlying player'sbrand_id, reject hard regardless ofMULTI_BRAND_ENFORCEMENTmode (recovery is too sensitive for observe). Recovery responses for unknown accounts produce the same response shape as known accounts (no existence oracle). Updateplayer-service.mdandagent-service.mdto document this contract. - Make
agent_settinglookups and writes keyed by(agent_id, brand_id). - Make
agent_domainlookups and writes brand-scoped; ensure a domain cannot be bound to two brands. - Update
player_service/app/api/helpers.pyso thatUSE_LEVEL_DOMAINcontinues to fire when a player tries to log in on a level domain bound to a different brand than their player row. - Update player-owned outbox payloads to include
brand_id. - At Phase 4 producer-deploy time,
player_serviceemits oneBRAND_AWARE_PUBLISHING_BEGINSsentinel event on the player stream so consumers can log the exact stream offset whereschema_versionbumps from 1 to 2. - Audit all Redis cache keys derived from user-supplied strings
(e.g.
account,phone,email,domain) and prefix every shared- namespace key withbrand_code. Concrete first targets:sms_captcha_{phone}->sms_captcha:{brand_code}:{phone}, any login-throttle key keyed onaccount-> add{brand_code}:prefix. Keys derived from surrogateplayer_idneed no change becauseplayer_idis brand-distinct. Document the convention indocs/services/player-service.md. - Add tests covering: cross-brand independent registration of the
same
accountstring, cross-brand independentagent_setting, per-brandagent_domain, and theUSE_LEVEL_DOMAINbrand-mismatch case. - Run
player_servicetests. - Commit Phase 4.
Phase 5 -- Issue Brand-Aware JWTs
Delivered by: 910ab341, 5c77fc81
- Add
brand_idto the JWT claim schema used byplayer_serviceandagent_service. Existing tokens withoutbrand_idremain valid until natural expiry; theMULTI_BRAND_ENFORCEMENT=observeflag from Phase 3 guarantees this. - Update login, refresh, and logout flows in
player_serviceandagent_serviceto include and preservebrand_id. - Refresh-token rotation only mints brand-aware access tokens. A
refresh from a brand-unaware refresh token (issued before Phase 5)
forces re-login by returning the documented re-auth-required error
envelope rather than minting a token that lacks
brand_id. Confirm refresh-token TTL: if longer than2 * JWT_EXPIRE_MIN, the soak window in Phase 16 must be extended to2 * REFRESH_TOKEN_TTLso every brand-unaware refresh token has expired before hard-flip. - Switch JWT signing from HS-family to RS256. Generate
JWT_PRIVATE_KEYandJWT_PUBLIC_KEYper environment; private key is provisioned only intoplayer_serviceandagent_service; public key is provisioned intogatewayand any other verifier. JWT header carrieskid(current key ID); verifier rejects unknownkidand rejects anyalgother thanRS256(noalg=none, no HS-family fallback). - Update internal handlers to read
X-Brand-Idfrom the request, not from JWT, so internal callers carry the brand in headers. - Add tests for: login issues a token with the correct
brand_id, refresh preserves it, observe-mode logs JWT/domain mismatches without rejecting, cross-brand JWT replay is logged in observe mode (and will be rejected after Phase 16). - Run
player_serviceandagent_serviceJWT tests. - Commit Phase 5.
Phase 6 -- Wallet Service Brand Scoping
Delivered by: 63db9df4, c218e91d
- Update
wallet_topology_store.pyso the active topology and active policy are resolved perbrand_id. - Update
wallet_bucket_commands.pyso every command resolvesbrand_idfromX-Brand-Idand rejects any target row whose brand differs. - Verify the
X-Brand-SignatureHMAC on every brand-scoped wallet write command. Inenforcemode, missing or invalid signature is rejected with the documented error envelope; inobservemode, incrementwallet_brand_signature_failed_total{caller_service,reason,mode}and proceed. Read paths and lower-stakes endpoints do not require the signature in this iteration (documented as a Phase X follow-up). - Add a
wallet_cross_brand_rejected_total{command}counter and emit it on every rejection. - Update topology and policy CRUD routes
(
servers_v2/wallet_service/app/api/routes/topology.py) so that per-brand uniqueness is honored and activation never crosses brand boundaries. - Update wallet outbox event payloads to include
brand_id. - At Phase 6 producer-deploy time,
wallet_serviceemits oneBRAND_AWARE_PUBLISHING_BEGINSsentinel event on the wallet stream so consumers can log the exact stream offset whereschema_versionbumps from 1 to 2 (perevent-catalog.md). - Add tests for: cross-brand rejection on every command (deposit approve, withdraw create, withdraw refund, adjustment, bet authorize, bet settle, bet rollback, transfer, points transfer, coupon grant, coupon reverse, points credit), per-brand topology resolution, per-brand policy resolution, per-brand activation isolation.
- Run
wallet_servicetests. - Commit Phase 6.
Phase 7 -- Rolling Service Brand Scoping
Delivered by: e16d0974
- Update rolling event consumer so it reads
brand_idfrom theDomainEventenvelope and persists it. If the envelopebrand_iddoes not match the target rolling row's brand, applyMULTI_BRAND_ENFORCEMENTsemantics:observelogs + counts and proceeds using the envelope brand;enforcerejects the event and surfaces the mismatch to supervision. - Update rolling progress and completion routes to require
X-Brand-Idand reject mismatched player rows. - Resolve per-brand rolling completion ratios from
brand_config(with documented global default fallback). - Update rolling outbox payloads to include
brand_id. - At Phase 7 producer-deploy time,
rolling_serviceemits oneBRAND_AWARE_PUBLISHING_BEGINSsentinel event on the rolling stream. - Add tests for: brand-scoped consumption, brand-scoped completion, per-brand ratio resolution.
- Run
rolling_servicetests. - Commit Phase 7.
Phase 8 -- Promotion Service Brand Scoping
Delivered by: c56438b5
- Update coupon definitions, event configs, and rebate/cashback/ lossback rates to be per-brand.
- Update settlement schedulers to iterate in
brand_idascending order, sequentially (one brand at a time). Each brand iteration is observable in worker logs (structured fieldbrand_id+ brand start/end timestamps). - Update coupon saga and points credit calls to
wallet_serviceto forwardX-Brand-Id. Saga consumers handling envelopebrand_idmismatches applyMULTI_BRAND_ENFORCEMENTsemantics (observe: log + count + proceed using envelope brand;enforce: reject and surface to supervision). - Update promotion outbox payloads to include
brand_id. - At Phase 8 producer-deploy time, IF a promotion-owned outbound
stream exists at that point (per
event-catalog.mdit is currently a Known Gap), emit oneBRAND_AWARE_PUBLISHING_BEGINSsentinel on it. If no promotion-owned stream exists, skip this task and updateevent-catalog.mdKnown Gaps to note that promotion remains stream-less in the multi-brand rollout (so the Phase 16 release gate'sevent_legacy_schema_totalzero-check does not require a non-existent stream). - Add tests for: per-brand coupon resolution, per-brand rebate / cashback / lossback resolution, brand-scoped settlement, brand-aware saga.
- Run
promotion_servicetests. - Commit Phase 8.
Phase 9 -- Game Service Brand Scoping
Delivered by: 8da5848a
- Audit every supported provider's account-field length cap and
document the result in
docs/services/game-service.md. For any provider whose cap is exceeded bylen(brand_code) + 1 + MAX_PLAYER_ACCOUNT_LEN(worst case16 + 1 + 32 = 49), document a per-provider fallback format (shorter separator, hash projection, or alternative encoding) before any code lands. This audit must run before Phase 4 enforcesMAX_PLAYER_ACCOUNT_LENat registration, so any account-cap concession is known before users register at the new cap. - Add a deposit-brand reconciliation step: scan every
player_depositrow created between0024adeploy and Phase 5 deploy. Cross-check the row'sbrand_idagainst the originating request's domain (logged by gateway at creation time, recoverable from access logs). Flag any deposit whose row brand differs from its origin domain's brand for manual review. This catches the edge case where a deposit landed during the migration window with a stale brand assignment. - Confirm or remove
/sbo/*from the gateway public-route allowlist. Resolved 2026-05-06:sboconfirmed dead (nosbo_callback.pyingame_service, no provider record, no callers). Removed/sbo/fromservers_v2/gateway/app/middleware/auth.py, dropped the/sbo/*row fromservers_v2/gateway/CLAUDE.md, retiredIntegrationType.SBO(value6reserved as a tombstone), and deletedsbofrom the bounded provider-label list inservers_v2/game_service/tests/test_observability_metrics.py. - Add an outbound account-namespacing helper in
game_servicethat returns{brand_code}_{account}(or the documented per-provider equivalent). - Update every outbound provider call to use the namespacing helper.
- Add an inbound reverse-parse helper that performs
brand_codeprefix lookup against thebrandtable (longest matching prefix wins; cache per process). Returns(brand_id, player_account)or fails. - Update every callback handler under
/HO/*,/mg/*,/wc/*,/bti/*,/splus/*,/bt1/*,/digitain/*,/integration/*to use the reverse-parse helper before resolving the bet, settlement, or rollback. (/sbo/*confirmed dead and removed; see above.) - Add a
game_callback_brand_unresolved_total{provider}counter and emit it on reverse-parse failure; rejected callback returns the documented error envelope. - Update game provider transaction state tables to carry
brand_id:ho_transaction,mg_account,mg_transaction,wc_account,bti_balance_change,bti_commit_reserve,bti_credit,bti_debit_reserve,bti_reserve,digitain_credit_batch,digitain_debit,digitain_rollback,digitain_token,digitain_transaction(and any other provider-state table found inservers_v2/shared/rgb_db/src/rgb_db/models/). - Add tests for: outbound namespacing per provider, inbound
reverse-parse per provider including the longest-prefix algorithm
with accounts containing
_, cross-brand callback rejection in bothobserveandenforcemodes, unresolved-callback counter, per-provider fallback format if any. - Run
game_servicetests. - Commit Phase 9.
Phase 10 -- Recon Service Brand Scoping
Delivered by: 2a6e9728
- Add
brand_idto theshooter_*table family. - Update recon match logic to resolve
brand_idfrom the matched deposit record. - Confirm approvals continue to flow through
wallet_serviceand exercise thewallet_servicecross-brand rejection on a synthetic brand-mismatch case. - Add tests for: brand-scoped recon match, brand-mismatch rejection on approval.
- Run
recon_servicetests. - Commit Phase 10.
Phase 11 -- Agent Service: Global Agent + Brand Allow List
Delivered by: 06e853f2
- Confirm the
agenttable carries nobrand_id. - Add an
agent_brandrepository inagent_servicethat exposes read-only allow-list lookup. - Add agent-edge enforcement: every authenticated agent request whose
resolved brand is not in the agent's
agent_brandallow list is rejected with the documented error. - Confirm brand resolution and allow-list enforcement also apply
to
agent_service's legacy aliases including/api/v1/user/login(per spec, the legacy alias surface is not exempt). Add a test that exercises the legacy alias under bothobserveandenforcemodes. - Strip any inbound
X-Brand-Idheader onagent_service's agent-frontend edge before injecting the resolved value (mirrors the gateway strip in Phase 3, per spec### Brand resolution at the edge). Add a header-spoofing test that asserts a request withX-Brand-Id: 99from the agent frontend is overwritten with the domain-resolved brand. - Update agent-side queries (player lists, settlements, withdrawals,
messages) to filter by
brand_id. - Update agent-domain outbox payloads to include
brand_id. - At Phase 11 producer-deploy time,
agent_serviceemits oneBRAND_AWARE_PUBLISHING_BEGINSsentinel event on the agent stream. - Add tests for: allow-list enforcement, cross-brand query isolation, brand-scoped agent lookups.
- Run
agent_servicetests. - Commit Phase 11.
Phase 12 -- Admin Service: Brand Catalog, Configuration, Allow List, And Staff Removal
Delivered by: dd8a0007
- Add a Phase 12 re-seed verification step: run
SELECT count(*) FROM agent a LEFT JOIN agent_brand ab ON a.agent_id=ab.agent_id WHERE ab.agent_id IS NULL. If non-zero, run a one-shot UPDATE that inserts(agent_id, default_brand_id, 'enabled')for every missing agent. Repeat until count is zero. This catches any agent created in a window where the autoseed trigger was not yet installed (e.g. an environment migrated from an earlier branch). - After Phase 12 ships the application-level
agent_brandwrite on agent create, run migration0028_remove_agent_brand_autoseed_trigger.pyto drop the temporary trigger. - Add
servers_v2/admin_service/app/api/routes/brand.pywith brand catalog CRUD: list, create, edit metadata, enable, disable. Reject edits tobrand_code. Brand-create rejects anybrand_codethat is a prefix of an existingbrand_code, has an existingbrand_codeas a prefix, or collides with the prefix of an existingplayer.accountvalue already namespaced and sent to a game provider (validated by scanningplayer.accountfor any legacy account starting with<brand_code>_). - Add
admin_servicemiddleware that reads theX-Operator-Idheader (injected by the SSO LB) and rejects every write request without it. Per-write audit row storesoperator_id, request IP, request_id, timestamp, payload, and prior value. Read requests do not requireX-Operator-Idbut are still logged with operator identity in structured logs. - Publish
BRAND_CATALOG_CHANGEDRedis pub/sub message after every successful brand create / disable sogame_servicereverse-parse caches invalidate within seconds. PublishAGENT_BRAND_CHANGEDafter everyagent_brandwrite soagent_serviceallow-list caches invalidate. - Add
servers_v2/admin_service/app/api/routes/brand_config.pywith per-brand configuration CRUD; every write is audited (timestamp, payload, prior value). - Add
servers_v2/admin_service/app/api/routes/agent_brand.pywith agent-to-brand allow-list CRUD; audited writes. - Update existing admin operational routes that touch a single
brand's data to accept an explicit
brand_idparameter and to forward it asX-Brand-Idon internal calls. - Replace the hard-coded
projectinteger inadmin_service/app/tasks/tag.pywithbrand_configresolution. - Audit and delete any global config constant or
global_varrow that is now duplicated by per-brandbrand_configentries (rolling ratios, cashback / rebate / lossback rates, payment channel selection, withdrawal min/max, risk thresholds). Document the enumerated deletion list in the migration body. Values that remain globally useful become "documented global defaults" used only whenbrand_confighas no brand-specific override. - Remove staff-related globals that become dead code with the
removal of the seven
admin_servicelegacy route files: legacy admin auth secret env vars, legacy session config, supporting helper modules. Trim any unused entries fromservers_v2/.env.compose.example. - Delete the following
admin_servicelegacy route files and their registrations inadmin_service/app/api/routes/api.py(and any sub- router include in the deleted files themselves):legacy_admin_v2.py,legacy_auth.py,legacy_agents_v2.py,legacy_agent_withdrawals_v2.py,legacy_meta_v2.py,legacy_recon.py,legacy_web_content.py. - Delete the supporting staff identity logic that becomes unimported
after the seven file deletions:
_authenticate_legacy_admin,_refresh_legacy_admin_token,_json_with_token, and any service module exposed only to those routes (e.g.legacy_web_contentservice). - Run
grep -rwE 'legacy_admin_v2|legacy_auth|legacy_agents_v2|legacy_agent_withdrawals_v2|legacy_meta_v2|legacy_recon|legacy_web_content' servers_v2/(word-boundaried) to confirm no surviving import references the removed modules. - Update
tests/test_admin_integration.pyandtests/test_admin_v2_compat.pyto remove staff coverage and add removal coverage: every previously served path under/api/admin/user/*,/api/admin/pushbullet/*,/api/admin/shooter/*,/api/admin/web/rules/*,/api/admin/web/faq/*, and/api/admin/web/config/*returns404. - Add tests for brand catalog CRUD,
brand_configCRUD, andagent_brandCRUD, including audit-log assertions. - Run
admin_servicetests. - Commit Phase 12.
Phase 13 -- Observability Wiring
Delivered by: e2d25f0f
- Add
brand_idandbrand_codeto the structured log enrichment used in every service. - Add a
brandPrometheus label (value:brand_code) to player-, wallet-, rolling-, promotion-, and game-scoped counters and histograms. - Add
brand_resolution_failed_total{reason,service}at the edge (gateway,agent_service,game_service, Plisio callback path). - Confirm
wallet_cross_brand_rejected_total{command,mode}andgame_callback_brand_unresolved_total{provider}are wired. - Wire the additional observability counters from spec
## Observability:brand_resolution_failed_total{reason="missing_header",service};multi_brand_enforcement_mode{service}gauge (per service);brand_resolution_latency_seconds{service}histogram on the edge hot path;request_total{brand_code,service}for per-brand legitimate traffic;event_legacy_schema_total{stream,consumer}for stale-schema events;security_downgrade_total{service}for startup-time downgrade detection;wallet_idempotency_brand_split_totalfor cross-brand idempotency collisions;wallet_brand_signature_failed_total{caller_service,reason,mode}for HMAC verification failures. - Confirm every metric label set is bounded enums; no host, account, raw IP, or attacker-supplied value enters as a label.
- Add alert definitions to the observability runbook layer (or to the existing per-service alert config) for the three new counters and for per-brand wallet outbox publication freshness.
- Add tests asserting log fields and metric labels.
- Run cross-service observability tests.
- Commit Phase 13.
Phase 14 -- Local Docker Two-Brand Validation
Delivered by: b73cc22c
- Seed two brands (
defaultandbrand2) in the local Docker stack, each with a distinct domain and a distinctbrand_code. - Configure two domains in the local Docker
domain:agent:*anddomain:level:*Redis maps, one per brand. - Run an end-to-end flow on
default: register, login, deposit, bet, settle, rolling, withdraw, coupon issue and use, points credit. - Run the same end-to-end flow on
brand2with a player whoseaccountstring equals an existingdefaultbrand player; confirm two independentplayerrows and two independent wallet states. - Confirm a JWT issued for
brand2is rejected ondefault's domain atgateway. - Confirm a wallet command targeting a
defaultplayer from abrand2request is rejected atwallet_serviceand the rejection counter increments. - Confirm a game callback for
brand2reverse-parses correctly and routes to thebrand2player. - Confirm per-brand
brand_configoverrides take effect (e.g. rolling ratio, cashback rate) and that documented global defaults still resolve when a brand has no override. - Confirm structured logs and Prometheus metrics carry brand labels.
- Adversarial test sweep: (a) every
GETendpoint exercised with a no-brand-claim JWT against the wrong domain returns either auth-error or empty data, never cross-brand data; (b) recovery flow with the same email registered in two brands recovers each independently with brand-scoped tokens and never leaks the existence of an account in the other brand; (c) a synthetic service-to-service spoof (process holdingINTERNAL_SERVICE_TOKENbut noBRAND_SIGNING_KEY) is rejected bywallet_servicefor a brand-scoped write command; (d) a JWT withalg=noneoralg=HS256is rejected bygateway; (e) brand-create with prefix-collision onbrand_codeis rejected byadmin_service; (f) thesecurity_downgrade_totalalert fires when a service starts inobservewhile more than one brand is enabled. - Confirm every worker (
wallet_worker,rolling_worker,promotion_worker,agent_worker,admin_worker,recon_worker) reports freshness-based readiness perADR-003after the migration with brand-aware events flowing in both brands; a payload-schema change to addbrand_idmust not silently break a freshness probe. - Commit Phase 14.
Phase 15 -- Stable Doc Refresh And Spec Promotion
Delivered by: (this commit)
- Re-verify the following stable docs reflect the implemented
behavior:
docs/architecture/data-ownership.md,docs/architecture/domain-ownership.md,docs/architecture/system-overview.md,docs/architecture/http-entrypoints.md,docs/architecture/event-catalog.md,docs/architecture/service-catalog.md,docs/architecture/deployment-topology.md,docs/architecture/migration-readiness.md, and everydocs/services/*.md. - Update
Last Verified Commitmarkers on each touched stable doc. - Promote
docs/specs/multi-brand/2026-04-27-multi-brand-isolation-spec.mdstatus toApproved. - Promote
docs/runbooks/multi-brand/multi-brand-isolation-rollout.mdstatus toReadyonly after all preceding phases are green. - Commit Phase 15.
Phase 16 -- Flip JWT/Domain Brand Enforcement To Hard-Reject
- Confirm the
brand_resolution_failed_total{reason="jwt_domain_mismatch"}counter has been at zero across normal traffic for the runbook's documented soak window. The soak window starts at the moment Phase 5 (brand-aware JWT issuance) is deployed in the target environment, not at the Phase 3 deploy time, because Phase 5 deploy is the last moment a brand-unaware JWT could be issued. Window length: at least2 * JWT_EXPIRE_MINminutes; with the defaultJWT_EXPIRE_MIN = 60, a minimum of 120 minutes from Phase 5 deploy. - Confirm every player- and agent-facing JWT in active use carries
brand_id(verified by sampling representative tokens and by the soak counter being zero). Tokens issued before Phase 5 are guaranteed expired by the soak window length. - Confirm
wallet_cross_brand_rejected_total{mode="observe"}has been at zero across normal traffic for the same soak window (proves every internal caller forwardsX-Brand-Idcorrectly). - Confirm
event_legacy_schema_total{stream,consumer}has been at zero across the soak window (proves every legacy-schema event has been drained from every stream before consumers flip to enforce; otherwise post-flip consumers will reject leftover legacy events and surface to supervision). - Confirm
wallet_brand_signature_failed_total{mode="observe"}has been at zero across the soak window (proves every brand-scoped wallet write caller produces a validX-Brand-Signature). - Confirm
brand_resolution_failed_total{reason="missing_header"}has been at zero across the soak window (proves every internal caller forwardsX-Brand-Idon brand-scoped routes). - Confirm
PER_CALLER_TOKEN_REQUIRED=onhas been advanced ANDinternal_caller_token_legacy_rejected_total == 0across the soak window (proves every internal caller has migrated to the per-caller token; legacy single-shared-token issuance is fully drained). - Confirm
VERIFY_CALLBACKS=trueANDgame_callback_verification_bypassed_total == 0across the soak window (proves every game-provider callback path is signature-checked and no provider is silently grandfathered into a bypass). - Confirm
BRAND_SIGNING_KEYis non-empty in the target environment AND the boot guard reports it green at start (the wallet/admin/etc. startup checks fail-closed on empty in production but the precheck here catches drift in staging). - Confirm
game_callback_brand_unresolved_total{reason="raw_*_in_enforce"} == 0across the soak window (proves no provider is hitting the raw-payload fallback for brand resolution under enforce mode). - CR3-A sync: confirm
agent_brandrow count covers every activeagent. Run:SELECT (SELECT count(*) FROM agent) AS agents, (SELECT count(DISTINCT agent_id) FROM agent_brand) AS bound;and confirm both numbers match. If they diverge, run the Phase 12 one-shot re-seed and re-check before proceeding (otherwise the affected agents would be locked out at first request post-flip). - CR3-A sync: confirm every internal HTTP caller sends an
X-Caller-Serviceheader. Runcd servers_v2 && uv run pytest tests/test_per_caller_header_audit.py -vand confirm 0 failures. This regression net catches any new internal client that forgets the per-caller identifier (would surface asinternal_caller_token_legacy_totalnon-zero immediately after the flip). - CR3-A sync: confirm
internal_caller_token_legacy_total == 0across the soak window for every consumer service. Per-consumer query:sum by (consumer) (rate(internal_caller_token_legacy_total[15m])) == 0. A non-zero value means at least one caller is still authenticating with the sharedINTERNAL_SERVICE_TOKEN; resolve before proceeding. - CR3-A sync: confirm Stage C (revoke shared
INTERNAL_SERVICE_TOKEN) has completed in this environment. Verify byprintenv | grep INTERNAL_SERVICE_TOKENon each consumer container; only the per-caller variants (INTERNAL_SERVICE_TOKEN_<CALLER>) should be present, the bareINTERNAL_SERVICE_TOKENmust be absent. - CR3-A sync: confirm
WALLET_BRAND_SIGNATURE_REQUIRE=onin the target environment ANDwallet_brand_signature_missing_total{caller_service=*} == 0across the soak window. A non-zero value identifies a caller that is not signing brand-scoped writes; resolve before proceeding. - CR3-A sync: confirm
wallet_topology_default_brand_fallback_total == 0across the soak window. A non-zero value means the per-brand topology lookup silently fell back to the default brand for at least one request -- root-cause before the flip (likely a missing per-brand topology row). - CR3-A sync: on-call has paged backup, opened the incident
channel placeholder, started the 30-minute wall-clock budget
timer, AND has the multi-brand counter Diagnosis Playbook open in
a tab (
docs/runbooks/multi-brand/diagnosis-playbook.md). Every pre-flip counter check above has a per-counter playbook section there for the case where the soak window is non-zero and root-cause is needed before proceeding. - Flip
MULTI_BRAND_ENFORCEMENTfromobservetoenforcein the target environment. Time budget: 30 minutes wall-clock total for the full sequence. Per-step budget: 3 min restart + readiness per service-and-worker group. Sequence (downstream first, edge last; this minimizes the window where edge enforces while downstream still tolerates and accepts the inverse small-window risk where downstream rejects a missing-header request before edge catches it):- wallet_service + wallet_worker
- rolling_service + rolling_worker
- promotion_service + promotion_worker
- player_service
- recon_service + recon_worker
- agent_service + agent_worker
- gateway
- Between each step: confirm
/healthon the just-flipped service showsenforcemode ANDmulti_brand_enforcement_modegauge is 2 ANDwallet_cross_brand_rejected_total{mode="enforce"}rate is at baseline (zero or known background). If any check fails, abort and revert (see below). - Mid-flip abort criteria: any of: a single-service step
exceeds 5 min wall-clock; readiness check fails twice; alert fires
on
wallet_cross_brand_rejected_total{mode="enforce"}non-zero during the flip; total budget exceeds 30 min. - Mid-flip revert procedure: flip every service that has
reached
enforceback toobservein reverse order (gateway first, wallet last). Same per-step budget. Document the revert in#incidents; root-cause the failure before re-attempting flip. - Final verification after the full sequence: confirm every
service reports
enforcevia Prometheusmulti_brand_enforcement_mode == 2. Confirm thesecurity_downgrade_totalcounter remains zero in subsequent hours (would fire if any service later re-bootstrapped with a different mode). - Confirm
gatewayrejects synthetic cross-brand JWT replay and synthetic JWT-without-brand_idrequests with the documented error. - Confirm normal traffic is unaffected (no spike in legitimate rejections).
- Update the runbook's release gate to mark this environment as hard-enforce.
- Commit Phase 16.
Risks
- A missing
brand_idfilter on a query is a data-leak risk between brands. Repository helpers and SQLAlchemy mixins must make brand filtering hard to forget; reviews must focus on raw SQL paths. - A wallet command path that bypasses brand validation is a cross-brand money mutation. Wallet command tests must cover every command family.
- JWT/domain mismatch enforcement enabled before every downstream
service is deployed with brand awareness will lock players out.
Mitigation: the
MULTI_BRAND_ENFORCEMENTflag introduced in Phase 3 defaulted toobserve(log-only) during the rollout and is flipped toenforceonly after Phases 4 through 14 are deployed and the soak window shows zero legitimate rejections. The repository compose default is nowenforceafter the 2026-05-06 in-repo closure. - Game provider account namespacing changes the outbound account string. Provider-side reconciliation must be reviewed before cutover.
- Backfill on a large existing database can be slow; migrations must be designed to default new columns at the schema level rather than rewriting every row in a single transaction.
- Removing staff routes also removes any in-flight back-office identity capability; consumers of those routes must be confirmed inactive before deletion.
- Per-brand topology and policy resolution adds an extra brand dimension to ADR-005's activation safety; activation must remain blocked when unresolved balances or active records would become unreachable, now scoped per brand.
Done Definition
The plan is done when every acceptance criterion in
docs/specs/multi-brand/2026-04-27-multi-brand-isolation-spec.md (the
## Acceptance Criteria section) is verifiably satisfied AND every
release-gate item in
docs/runbooks/multi-brand/multi-brand-isolation-rollout.md (the
## Release Gate section) is satisfied for the target environment,
plus the following plan-specific gates:
- Spec status is
Approved. - ADR-009 is
Accepted. - Phases 1 through 16 are complete.
- Local Docker two-brand validation passes end to end (see runbook).
- All service test suites pass.
- The migration verification script reports zero NULL
brand_idrows across every brand-scoped table. MULTI_BRAND_ENFORCEMENT=enforceis active in every environment that has cleared its soak window (per Phase 16).- No brand-scoped query in any service is missing a
brand_idfilter (verified by code review of the merged PRs). - The seven listed
admin_servicelegacy route files are deleted and their previously served paths return404. - ADR-005's "single money writer" rule is unchanged after this work.
- Stable docs (architecture and services) reflect the implemented
behavior and have updated
Last Verified Commitmarkers. - Runbook is promoted to
Readyand is followed for staging validation before any production enablement of a second brand.