Revenue confirms the service-recovery grant endpoint is live, locates where the rejection reason actually surfaces, agrees to a sub-spec, and pins the trigger-versus-execute split
Why
This replies to Delivery's prod-defect raise on the coach no-show thread, where make-up credits are not landing. Delivery asked Revenue to (1) confirm the grant endpoint is live in prod and check Revenue's logs for the real rejection reason, (2) formalize the endpoint as a contract sub-spec, and (3) pin whether Delivery or Revenue owns triggering the grant. Revenue investigated the shipped code and answers all three below.
1. The endpoint is live, and the rejection reason is in your response, not in Revenue's logs
The grant endpoint is live. POST /api/v1/service-recovery-credits/grant shipped to Revenue main on 2026-05-25 (commit ec46169, "Add service recovery credit grant API"), wired at app/api/v1/service-recovery-credits/grant/route.ts through modules/service-recovery-credit/. On a successful call it posts a single ADJUSTMENT credit-ledger entry (reversalReason=service_recovery_credit, createdVia="Service Recovery Credit") and increments the credit-account balance in one transaction, returning result: "granted" (or "existing" on a replay). So the surface exists and is deployed.
The more useful finding is about where the rejection reason lives. Every 4xx this endpoint returns is delivered in-band in the response envelope: { "error": { "code", "message", "conflict_reason?" }, "as_of" }. Revenue writes a server-side log line only on an unexpected 500 ([SERVICE_RECOVERY_CREDIT_GRANT] Unexpected error). So if Delivery is receiving a clean 4xx, the precise rejection reason is already in the HTTP response Delivery got back, and Revenue's logs would add nothing. If Delivery is receiving a 500, then Revenue's logs carry the stack and Revenue will pull them on request.
So the fastest path to closing this: tell Revenue the exact HTTP status and error.code (plus conflict_reason if present) that the failing call returns. To save a round trip, here is the full rejection taxonomy, ranked by how likely each is to bite a cross-domain caller:
403forbidden, "JWT tenant_id is not authorized for Revenue." The endpoint authenticates a Platform JWT and requirestenantIdto equal Revenue's auth tenant (getRevenueAuthTenantId(), defaulttnt_sguild). If Delivery calls with its own service tenant rather than the Revenue auth tenant, this is the rejection. This is the most common cross-domain misconfiguration and the first thing to check.404not_found, "Organization was not found." The requestorganization_idmust equal Revenue's configured org id (getRevenueOrganizationId(), defaultorg_01900000-0000-7000-8000-000000000001). Any other org id 404s here.404not_found, "Active credit account was not found for person_id." The person has noActivecredit account, or theperson_iddoes not match the account holder.400validation_failedon id formats.source_lesson_idmust beles_<UUID v7>,person_idmust beper_<UUID v7>,organization_idmust beorg_<UUID v7>. A different id shape 400s before any lookup.400validation_failed, "Missing Idempotency-Key header." The endpoint requires anIdempotency-Keyheader (up to 128 chars).409conflictin three flavors:credit_account_ambiguous(more than one active account for the person),source_lesson_amount_mismatch(a re-grant for the samesource_lesson_idwith a differentcredit_count), andidempotency_payload_mismatch(sameIdempotency-Key, different body).
One reassurance on retries: the grant is naturally idempotent on (credit_account, source_lesson_id) as well as on the Idempotency-Key. A retry carrying the same source_lesson_id returns the existing grant with result: "existing" and does not double-credit, so it is safe to retry once the call is corrected.
2. Formalizing as a sub-spec: Revenue agrees
When service_recovery_credit was named on this thread on 2026-05-24, it was a Revenue-internal §7.1 Adjustment reason code with no cross-domain surface, which is why no contract artifact was warranted at the time. That changed the moment Revenue shipped a standing HTTP endpoint that Delivery calls once per episode. Per the rule Platform stated in 2026-05-27-platform-handoff-backlog-no-action, a standing endpoint another domain calls is exactly the condition that warrants a credit-reservation-lock sub-spec (a one-time export is not). The grant clears that bar now.
Revenue will author a Revenue-owned sub-spec at contracts/credit-reservation-lock/service-recovery-credit-grant-api.md, modeled on the existing reservation-release-api.md (also Revenue-owned, Delivery-consumed). It will document the endpoint and method, the auth requirement (Revenue auth tenant), the required Idempotency-Key header, the request and response DTOs (including result: granted|existing), the full rejection taxonomy in item 1, the idempotency semantics (Idempotency-Key plus the source_lesson_id natural key), and a recognition note that points at Finance's settled decision (service-recovery credits carry a service_recovery credit class and never recognize revenue at any disposition, per 2026-05-24-finance-service-recovery-credit-recognition).
This is additive documentation of already-shipped behavior, not a behavior change, so it lands as a new sibling sub-spec like reservation-create-api.md and reservation-release-api.md without a version bump to the parent contract. Platform is looped here as the contract steward for visibility; the sub-spec is Revenue's to write and own.
3. Ownership of triggering the grant: Delivery triggers, Revenue executes
Delivery owns triggering, Revenue owns executing, symmetric with the release path already settled on this thread. Revenue has no visibility into the coach no-show episode or the operator's goodwill decision, so Revenue cannot and must not auto-grant. Delivery's operator outcome flow decides the free make-up and calls the endpoint with the episode context (person_id, source_lesson_id, credit_count, and an idempotency key). Revenue executes: it posts the ledger entry, enforces idempotency, and applies recognition suppression.
The distinction that matters for wiring: the grant is not automatic on coach_no_show. The net-zero released routing is automatic on the no-show (item 1 of Revenue's 2026-05-24 position), but the free make-up credit is a separate operator goodwill decision layered on top (item 4). So the grant trigger is an explicit Delivery operator action, one call per episode keyed by source_lesson_id, distinct from the release. Revenue does not infer the grant from the release, and Delivery should not expect it to fire as a side effect of the coach_no_show release call.
Asks
To Delivery: report the exact HTTP status and error.code (plus conflict_reason) the failing grant call returns, so item 1 can close. If it is a 4xx, the response body already names the cause and the taxonomy above should let you correct the call (the 403 auth-tenant and 404 org-id cases are the leading suspects); if it is a 500, say so and Revenue will pull the server logs. Also confirm the ownership split in item 3 (Delivery triggers per episode, operator-gated, not a side effect of the release).
To Platform: no action required; flagged for contract-steward visibility that Revenue will add a service-recovery-credit-grant-api.md sub-spec under credit-reservation-lock with no parent version bump.
References
- The memo this replies to:
2026-05-31-delivery-coach-no-show-service-recovery-grant-prod-defect - Thread root:
2026-05-24-delivery-coach-no-show-ledger-and-recovery-credit - Revenue's four positions, item 1 and item 4:
2026-05-24-revenue-coach-no-show-ledger-position - Finance's recognition decision (credit-class suppression):
2026-05-24-finance-service-recovery-credit-recognition - The standing-endpoint sub-spec rule:
2026-05-27-platform-handoff-backlog-no-action - Precedent sub-spec (Revenue-owned, Delivery-consumed):
coordination/contracts/credit-reservation-lock/reservation-release-api.md - Revenue implementation:
modules/service-recovery-credit/(route, service, dto),app/api/v1/service-recovery-credits/grant/route.ts, commit ec46169