# Android RTDN environment isolation

17 September 2026. **Implemented locally; not committed, deployed or enabled in Google Cloud by this task.** This extends the earlier renewal parser fix. iOS Step 5 remains separate.

## Design: independent delivery, guarded processing

Use two authenticated, wrapped push subscriptions on the **existing** topic `projects/conjugador/topics/conjugate-subscription-events`. Do not change the Play Console topic.

| Proposed subscription | Destination | Backend mode | Processes |
| --- | --- | --- | --- |
| `conjugate-subscription-events-staging-push` | `https://apidev.appconjugate.com/api/android-subscription-webhook` | `sandbox` | Google license-test purchases only |
| `conjugate-subscription-events-production-push` | `https://api.appconjugate.com/api/android-subscription-webhook` | `production` | Real purchases only |

These names describe the activation plan; this change does not create them. The existing `conjugate-auth-staging-test-push` continues to use the temporary connectivity topic and does not receive Play renewals.

Both backends **receive the envelope, including the purchase token, and query Google**. Only the matching backend processes it. This is processing/database isolation, **not** a transport-level firewall preventing production tokens from reaching staging. Staging must remain a trusted, access-controlled backend; do not log raw request bodies, Google responses, purchase tokens or authorization headers. If strict transport isolation is required later, use a separately authenticated central router instead of activating this fan-out design.

There is no backend-to-backend forwarding, new shared secret, cross-database query or account migration. Failure of staging does not require production to forward or wait for staging. Each Pub/Sub subscription has its own acknowledgement/retry state. [Google subscription overview](https://cloud.google.com/pubsub/docs/subscription-overview)

## Source of truth

`subscriptionsv2.get` returns `testPurchase: {}` for a test purchase; it is absent for a real purchase. PHP's associative JSON decoding produces `[]` for `{}`, so `empty()` must not determine the environment. The app version, API URL, RTDN message attributes, and `testNotification` are not purchase-environment evidence. Pub/Sub filters only inspect attributes; they cannot query this Google API flag. [Google subscription schema](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2), [Pub/Sub filtering](https://cloud.google.com/pubsub/docs/subscription-message-filter)

## Backend configuration

New setting: `GOOGLE_PLAY_WEBHOOK_PURCHASE_ENVIRONMENT`.

| Value | Effect |
| --- | --- |
| `legacy` (default if unset) | Prior owner-first webhook behavior, accepts either purchase environment. Backward-compatible deployment default, **not isolation**. |
| `sandbox` | Query Google first, process only test purchases. Required on staging before connecting the shared topic. |
| `production` | Query Google first, process only real purchases. Required for the isolated production delivery. |
| `disabled` | Authenticated, correctly wrapped/package-matched messages return HTTP 503 before lookup/mutation, including connectivity tests. Temporary retryable pause, not permanent message retention. |
| Empty/unknown value | HTTP 503, no fallback to legacy processing. |

Staging persistent `.env`:

```dotenv
GOOGLE_PLAY_WEBHOOK_PURCHASE_ENVIRONMENT=sandbox
GOOGLE_PLAY_PACKAGE_NAME=app.conjugate.and
GOOGLE_PUBSUB_SERVICE_ACCOUNT=conju-pubsub-staging@conjugador.iam.gserviceaccount.com
GOOGLE_PUBSUB_AUDIENCE=https://apidev.appconjugate.com/api/android-subscription-webhook
```

Production persistent `.env`, when production activation is approved:

```dotenv
GOOGLE_PLAY_WEBHOOK_PURCHASE_ENVIRONMENT=production
GOOGLE_PLAY_PACKAGE_NAME=app.conjugate.and
GOOGLE_PUBSUB_SERVICE_ACCOUNT=conju-pubsub-production@conjugador.iam.gserviceaccount.com
GOOGLE_PUBSUB_AUDIENCE=https://api.appconjugate.com/api/android-subscription-webhook
```

The production push service account is a **proposed identity**, not an existing verified resource. Match the actual approved identity in both Pub/Sub and backend config. Keep the existing Publisher API credentials and Telegram bot/chat settings private and configured per deployment. Push identity and Publisher API credentials have different jobs.

No native environment/flavor changes, endpoint changes, migrations or dependency updates are needed. Keep the app's manual `utils/Config.tsx` approach. Existing purchase/webhook tables from earlier work are prerequisites.

## Processing order and outcomes

1. Verify Google OIDC signature, audience, issuer and service-account identity (unchanged).
2. Validate wrapped JSON, message ID, package, notification family and reviewed notification type.
3. Validate the configured mode; paused/invalid mode returns 503. Console `testNotification` remains a logged no-op in enabled modes.
4. In sandbox/production modes, fetch Google state and validate the existing product/state/expiry requirements. Classify using the Google response only.
5. Other environment: return HTTP 200 with `ignored=true` and `reason=purchase_environment_mismatch`. No purchase lookup, purchase/user write, webhook-event row, acknowledgement request to Google or Telegram send occurs.
6. Matching environment: use existing event deduplication, require an already-linked local owner, update the existing purchase row from current Google state and synchronize the user's aggregate entitlement. Reuse the state already fetched; do not query twice within one delivery.
7. For type 2 with current entitlement, send the existing renewal Telegram notification. Persist completion only after processing succeeds. Other reviewed types update current entitlement without claiming a renewal alert.

| Outcome | Response | Persistence/retry |
| --- | --- | --- |
| Auth failure | 401 | No mutations; repair auth |
| Missing/invalid routing configuration or `disabled` | 503 | No mutations; retry |
| Google API/network/invalid state or malformed test marker during classification | 503 | No mutations or event row; retry, never guess production |
| Valid opposite environment | 200 ignored | No mutations; only this subscription's copy acknowledged |
| Matching token not yet linked locally | 404 | Failed webhook event `purchase_not_linked`; retry after app validation links it |
| Matching processed duplicate | 200 duplicate | No further writes or Telegram send |
| Matching Telegram failure while enabled | 500 | Updated entitlement retained; failed event `renewal_notification_failed` retries |
| Matching successful event | 200 | Existing purchase updated, webhook event processed |

In strict modes duplicate/ignored deliveries still re-query Google before local deduplication. Budget Publisher API/OAuth traffic for both subscriptions and retries; monitor quota/errors. A lookup outage can therefore delay even a previously processed duplicate's ACK, but never rolls back access. In legacy mode the original dedup-before-lookup ordering remains.

## Compatibility and rollback

- Receipt-validation endpoints, Android v2/compatibility selection, purchase-policy endpoint, acknowledgement helpers, iOS handlers and mobile code are unchanged.
- Real purchases made on 1.4.4, versionless clients and 1.4.6 are eligible in production. The webhook has no app version and must not use the 1.4.6 checkout gate. Legacy rows without a platform/hash remain adoptable by the matching backend.
- `ANDROID_PURCHASE_NEW_FLOW_ENABLED` still controls checkout only. It is not a webhook pause or environment switch.
- New mode `disabled` pauses webhook processing without preventing checkout, for both old and new clients. Messages can still expire or move to a dead-letter topic according to Pub/Sub settings; monitor backlog and recover promptly.
- `legacy` restores pre-routing webhook behavior without reverting the code. **Never use it on staging while the shared topic is connected.** If rolling back staging, pause it (`disabled`) and arrange its delivery safely first. Production legacy mode can process existing local sandbox rows again; explicitly accept that loss of isolation before using it.
- Do not roll back to code without the environment guard while staging remains attached to the shared topic: older code ignores this setting.
- The receipt endpoint is deliberately unchanged. A sandbox purchase originally validated against production stays in production; this feature does not move it to staging. A real purchase accidentally sent to staging is not automatically moved either. Confirm the APK URL and use the intended staging account for QA.

## Activation runbook — separate approval required

1. Review/commit/push the backend patch first. GitHub Actions cannot deploy these uncommitted local changes. Record the exact commit to deploy.
2. Set staging's persistent `.env` mode to `sandbox`; retain its separate DB, existing auth and Telegram settings. Deploy that commit using **GitHub Actions → OVH backend**, environment `staging`, operation `deploy`, that commit as `ref`, migrations unchecked for this patch. Verify successful completion, not just upload.
3. Use the existing config-refresh process. If a config cache actually needs clearing, the workflow's `artisan` operation accepts `["config:clear"]`. Do not run `config:cache` or `optimize`; the existing deployment runner forbids them. Do not rotate `APP_KEY`.
4. Re-test the temporary topic: 200 + `Google Play test notification received.` checks authentication/handler availability but does **not** prove the mode, Google lookup credentials, real renewal routing or Telegram. Verify the effective mode privately before the next step.
5. Obtain approval for live-topic cloud activation and production deployment. Inventory existing subscribers; do not delete/replace a production subscriber or create duplicate production consumers blindly. The last check on 17 September found none on the Play topic.
6. Prepare production with the same reviewed code and `production` mode, correct OIDC identity/audience, valid Google read credentials and existing tables. Do not claim it is protected just because a `.env` value was added to an older deployment. Confirm successful deployment through the existing production process; it may differ from staging.
7. Create two **independent authenticated push subscriptions** per the table, both referencing the existing Play topic. Use wrapped payloads (unwrapping OFF), exact endpoint audiences, 120-second acknowledgement deadline, and reviewed retention/retry/dead-letter policies. Do not filter by invented `sandbox` message attributes.
8. Use the dedicated staging identity already configured. Create/review a dedicated production push identity. Grant the Pub/Sub service agent `service-730823299874@gcp-sa-pubsub.iam.gserviceaccount.com` permission to mint tokens (Service Account Token Creator on each push identity). The configuring operator needs permission to act as that identity. Do not grant Owner/Editor or download keys just for push delivery. [Google push authentication](https://cloud.google.com/pubsub/docs/authenticate-push-subscriptions)
9. Keep Play Console pointed at `projects/conjugador/topics/conjugate-subscription-events`. Confirm Google Play's publisher identity `google-play-developer-notifications@system.gserviceaccount.com` has Pub/Sub Publisher on that topic. This is separate from push delivery authentication. [Google Play setup](https://developer.android.com/google/play/billing/getting-ready)
10. Run the acceptance checks below. Only after validation should the temporary topic/subscriptions be considered for deliberate cleanup; this task removes nothing.

If production activation is deferred, staging can only be connected after explicit approval with its `sandbox` guard verified. That does **not** restore real-customer renewal delivery; keep that gap explicit. Do not interpret a staging HTTP 200 skip as successful production processing. No consumer can recover historical notifications simply by creating a subscription now; historical reconciliation needs a separate scoped plan.

## Live acceptance checklist

- [ ] Both real-topic subscriptions exist and reference the intended topic/endpoint/identity/audience; deployed code and effective modes verified.
- [ ] Play Console test reaches each endpoint, each logs the no-op and returns 200, neither changes purchases or sends Telegram.
- [ ] An acknowledged sandbox purchase is already linked to the staging account. Close the app so restore/reconciliation cannot mask a missing webhook.
- [ ] After a genuine Google test renewal, staging logs type 2 processed; the same purchase row has later `expires_at` and refreshed `last_store_checked_at`; Telegram reports the Android renewal if enabled.
- [ ] The production copy is logged as `purchase_environment_mismatch`; no purchase/user/webhook-event row is written for it. Inspect through approved operational tools, without exporting customer data or tokens.
- [ ] Observe an authorized normal production renewal, without creating a real charge solely for QA: production processes it and staging skips it. Synthetic fixture tests do not replace this deployment check.
- [ ] Repeat 1.4.4 staging checkout/renewal and 1.4.6 with the Android checkout switch ON/OFF as appropriate; no mandatory client update.
- [ ] Verify controlled matching-message redelivery, Google outage/recovery and Telegram failure/recovery on staging, preserving access and preventing duplicate purchase rows. Telegram is not exactly once after ambiguous network delivery.
- [ ] Configure operational alerts for push non-2xx/backlog age, Google API quota and matching failed store events. Review dead-letter/retention limits; do not discard pending events just to make charts green.

## Diagnostics

Search each environment's private Laravel logs and match Pub/Sub message ID/time:

| Log | Meaning |
| --- | --- |
| `Google Play test notification received.` | Connectivity test only |
| `Google Play webhook skipped for other purchase environment.` | Expected non-matching copy; includes bounded mode/type/observed environment |
| `Google Play subscription notification processed.` | Matching event completed; includes product, state, mode and notification type |
| `Google Play webhook environment lookup failed.` | 503 before persistence; investigate Google credentials, API availability or state shape without logging tokens |
| `Google Play webhook processing failed.` | Matching processing failed; inspect bounded category, e.g. `purchase_not_linked` or `renewal_notification_failed` |

For newly classified skips/failures, absence of a `store_webhook_events` row is intentional. Use logs and Pub/Sub metrics instead. A mode correction does not automatically redeliver an already-ACKed ignored message; approved replay/seek requires suitable retention and deduplication, or use a fresh test renewal. Separate message IDs may produce separate Telegram alerts.

## Code and verification

- `app/Services/GooglePlayWebhookEnvironment.php`: strict mode parsing and Google test-marker classification.
- `app/Http/Controllers/PurchaseAndroidController.php::handleAndroidWebhook`: authenticated pre-write guard; matching flow reuse; bounded diagnostics.
- `config/services.php`, `.env.example`: opt-in backend-only setting and safe rollback options.
- `tests/Unit/Database/AndroidWebhookEnvironmentTest.php`: real repository/deduplication on SQLite memory with Google/auth/Telegram mocked, plus fake HTTP JSON-decoding tests.
- `tests/Unit/Http/Controllers/PurchaseAndroidControllerTest.php`: checkout versions/flow switches exercised under every webhook mode.

Run `php vendor/bin/phpunit tests/Unit --do-not-cache-result`. Final local result: **171 tests, 1,236 assertions passed**, including 33 new routing cases and 30 additional checkout-mode cases. PHP syntax checks and `git diff --check` passed. Receipt validation/v2 checkout and shared state/acknowledgement helper bodies were also compared byte-for-byte with the starting revision and are unchanged. These tests do not deploy, call real Google/Telegram, validate production MySQL locking, or certify live IAM/delivery.
