Azure / Azure/typespec-azure

[proposal] Reducing handwritten LRO customizations across Azure SDKs by generating from TypeSpec

Open
#5,432 3 comments 0 reactions 1 assignee Claimed by @iscai-msft View on GitHub
epic
Dominant language
TypeScript
Stars
27
Forks
90
Avg merge
1d 22h
Merged PRs (30d)
156

Description

## Problem Statement

Every language SDK we generate (Python, Java, C#, Go, JS, and others) currently ships hand-maintained customizations for LRO handling. These customizations exist due to a combination of not being able to express custom needs in TSP, and because language emitters do not fully consume the LRO metadata that TSP already exposes. Currently, our language emitters largely rely on client core code that re-derives polling / completion behavior by inspecting raw responses at runtime.

## Goal

**Primary:** drive down handwritten LRO customizations across Azure SDKs — *within reason*. The target is **zero _addressable_ customizations**: ones that exist only because of a fixable gap in TypeSpec, an emitter, or the LRO metadata. It is **not** zero customizations outright — some services are legitimately too non-standard for a shared standard to pay off, and those stay as a small, curated set of **Accepted** customizations, each with a recorded rationale. The *Standards vs. customization* section below defines how we sort addressable vs. accepted; *When to remove vs. when to build* covers how spec confidence gates when it is safe to remove.

**Secondary (stretch):** as those customizations come off, drive *response-inspection* — the emitter/client-core habit of re-deriving polling and completion by sniffing raw responses (headers, status re-derivation) at runtime — down toward zero, so generated polling/completion becomes a pure function of `getLroMetadata`.

Two levers, in priority order:
1. **Remove the override (primary).** Delete the handwritten customization; regenerate; the generated output should be unchanged or an intentional, reviewed improvement. This is what we track and burn down.
2. **Remove the crutch (secondary).** Even after the override is gone, the generated + client-core path may still inspect raw responses to decide polling/completion. Retiring that — so behavior is derived exclusively from the TSP definition — is the harder, later win we pursue through and after the removals.

## Current TSP LRO surface

TSP currently only supports Azure polling through `@azure-tools/typespec-azure-core`.

| Concern | Decorator(s) |
| --- | --- |
| Linking initial op to polling/final op | `@pollingOperation`, `@finalOperation`, `@operationLink`, `@pollingOperationParameter` |
| Where to find poll/result references | `@pollingLocation`, `@finalLocation` |
| Status monitor semantics | `@lroStatus`, `@lroSucceeded`, `@lroFailed`, `@lroCanceled` |
| Result / error extraction | `@lroResult`, `@lroErrorResult` |
| Completion mode | `@useFinalStateVia` |

Emitters query this via `getLroMetadata(program, operation)`, which returns:

| Field | Description |
| --- | --- |
| `logicalResponse` / `logicalPath` | Type/path of the operation's logical (post-polling) result |
| `final-state-via` | Equivalent to the OpenAPI `x-ms-long-running-operation-options` `final-state-via` |
| `statusMonitor` | Status monitor type (`envelopeResult`) |
| `polling` | Status field + terminal status values |
| `final` | How to resolve the final result once polling completes |

## Taxonomy: LRO shape x dimension

To bucket every handwritten customization we find, we'll classify it along two axes:

1. **Shapes**: from Azure REST API guidelines, which should have a corresponding `Azure.Core` template
- Long-running create-or-update (PUT/PATCH)
- Long-running delete
- Long-running action / RPC-style POST
- Custom / non-standard
2. **Dimensions**: from `LroMetadata`
- Polling location & mechanism (header vs. body vs. linked operation)
- Status field & terminal value detection
- Final result resolution (via status monitor / `Location` / linked operation)
- `final-state-via` / completion mode
- Error result shape
- Cancellation
- LRO + Paging
- Additional metadata to expose on the poller

Legend: ✅ tsp + emitter both handle it · 🔴 tsp has no way to express it (Bucket A) · 🟡 tsp supports it but emitter doesn't consume it (Bucket B) · ⚠️ `getLroMetadata` returns something but it's been observed to be wrong (Bucket C)

Spec confidence (Bucket D): 🟩 verified — confirmed accurate against live service behavior (typically greenfield, data-plane) · 🟧 migrated, unverified — ported from Swagger, not independently re-verified (typical brownfield/mgmt-plane state) · 🟥 known inaccurate — direct evidence the spec doesn't match reality

Cells can carry more than one marker (e.g. 🔴🟡 🟧 means: tsp gap + emitter gap found, in an operation whose spec is migrated-but-unverified)

_All cells below carry 🟩 (verified): every operation audited is a **greenfield, data-plane** service shipping a working hand-written customization, so per Bucket D we trust the spec/customization. Per-service evidence and deep links live in the **Audit findings** table below — the matrix shows only the shape × dimension bucket pattern._

| Dimension | Long-running create-or-update (PUT/PATCH) | Long-running delete | Long-running action / RPC POST | Custom / non-standard |
| --- | --- | --- | --- | --- |
| Polling location & mechanism | ✅🟡 🟩 | 🔴 🟩 | 🟡🔴 🟩 | 🔴 🟩 |
| Status field & terminal value detection | ✅ 🟩 | 🔴 🟩 | 🟡🔴 🟩 | 🔴 🟩 |
| Final result resolution | 🟡 🟩 | 🔴 🟩 | 🟡 🟩 | 🔴 🟩 |
| `final-state-via` / completion mode | ✅ 🟩 | 🔴 🟩 | ✅🟡 🟩 | 🔴 🟩 |
| Error result shape | ✅ 🟩 | 🔴 🟩 | 🟡🔴 🟩 | 🔴 🟩 |
| Cancellation | — | 🔴 🟩 | 🔴 🟩 | 🔴 🟩 |
| Paging + LRO combinations | — | — | 🔴 🟩 | 🔴 🟩 |
| Additional poller-exposed metadata | ✅ 🟩 | — | 🟡 🟩 | 🔴 🟩 |

## Audit findings (Phase 1) — LRO customization inventory

Cross-language sweep of every data-plane package flagged with an LRO/polling customization (Python, C#, Java, JS, Go). Each row is a service; a service is treated as **spec-verified (🟩)** because it ships a working hand-written LRO customization. Shapes: **CU** = create-or-update, **DEL** = delete, **POST** = action/RPC POST, **CUSTOM** = non-standard.

| Service | Shape | Langs w/ hand-written LRO | Dimensions customized | Bucket | Representative deep links |
| --- | --- | --- | --- | --- | --- |
| KeyVault Certificates | CUSTOM (pending-cert monitor) + DEL | Py, C#, Java, JS | status/terminal, final result (2nd GET), cancellation | 🔴 A | [Py `_polling.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_polling.py) · [C# CertificateOperation.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/keyvault/Azure.Security.KeyVault.Certificates/src/CertificateOperation.cs) · [JS keyVaultCertificatePoller.ts](https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/keyvault/keyvault-certificates/src/lro/keyVaultCertificatePoller.ts) |
| KeyVault Keys / Secrets | DEL (poll-until-404 delete/recover) | Py, C#, Java, JS | polling mechanism, terminal detection (404/403), error shape | 🔴 A | [Py `_shared/_polling.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_shared/_polling.py) · [C# DeleteSecretOperation.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/keyvault/Azure.Security.KeyVault.Secrets/src/DeleteSecretOperation.cs) · [JS keys delete/operation.ts](https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/keyvault/keyvault-keys/src/lro/delete/operation.ts) |
| KeyVault Administration (Managed-HSM backup/restore) | POST → `Azure-AsyncOperation` | Py, C#, Java, JS, Go | polling location (header), final-GET suppression, rehydration | 🟡 B / ⚠️ C | [Py `_internal/polling.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/keyvault/azure-keyvault-administration/azure/keyvault/administration/_internal/polling.py) · [C# KeyVaultBackupOperation.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/keyvault/Azure.Security.KeyVault.Administration/src/KeyVaultBackupOperation.cs) · [Go backup/custom_client.go](https://github.com/Azure/azure-sdk-for-go/blob/main/sdk/security/keyvault/azadmin/backup/custom_client.go) · [JS shim.ts](https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/keyvault/keyvault-admin/src/lro/shim.ts) |
| KeyVault Security Domain | CUSTOM (upload/download) | Py | terminal literal `"Success"`, initial-response result, `--no-wait` | 🔴 A (+B) | [securitydomain `_internal/polling.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/keyvault/azure-keyvault-securitydomain/azure/keyvault/securitydomain/_internal/polling.py) |
| Document Intelligence | POST + op-location | Py, C#, JS (Java generated) | poller-exposed metadata (`operation_id`) | 🟡 B | [Py `_operations/_patch.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/documentintelligence/azure-ai-documentintelligence/azure/ai/documentintelligence/_operations/_patch.py) · [C# OperationWithId.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/documentintelligence/Azure.AI.DocumentIntelligence/src/OperationWithId.cs) · [JS pollingHelper.ts](https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/documentintelligence/ai-document-intelligence-rest/src/pollingHelper.ts) |
| Content Understanding | POST + op-location | Py, C# (**Java: generated poller class, no hand-written poller**) | poller metadata (`operation_id`, `usage`) | 🟡 B | [Py models/`_patch.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/models/_patch.py) · [C# AnalyzeOperationExtensions.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/contentunderstanding/Azure.AI.ContentUnderstanding/src/AnalyzeOperationExtensions.cs) |
| Cognitive Language (TextAnalytics / Conversations) | POST + **paging** | Py, C# | final result = paged collection, poller metadata | 🔴 A | [Py textanalytics `_patch.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/cognitivelanguage/azure-ai-textanalytics/azure/ai/textanalytics/_patch.py) · [Py conversations `_patch.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/cognitivelanguage/azure-ai-language-conversations/azure/ai/language/conversations/_patch.py) |
| Translation (Document) | POST + **paging** + custom status | Py, C# | polling, status (`ValidationFailed`), paging+LRO, cancel, error shape | 🔴 A | [Py `_operations/_patch.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/translation/azure-ai-translation-document/azure/ai/translation/document/_operations/_patch.py) · [C# DocumentTranslationOperation.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/translation/Azure.AI.Translation.Document/src/DocumentTranslationOperation.cs) |
| Health Insights (Radiology) | POST, result nested under `result` | Py | final-result resolution (nested property) | 🟡 B / ⚠️ C | [Py `_patch.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/healthinsights/azure-healthinsights-radiologyinsights/azure/healthinsights/radiologyinsights/_patch.py) |
| Discovery | POST + DEL, non-standard completion | Py, C# | status via `provisioningState`, sync-200, delete-until-404 | 🔴 A / ⚠️ C | [Py operations/`_patch.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/discovery/azure-ai-discovery/azure/ai/discovery/operations/_patch.py) · [C# ProvisioningStateOperation.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/discovery/Azure.AI.Discovery/src/Customization/ProvisioningStateOperation.cs) · [C# DeleteUntilGoneOperation.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/discovery/Azure.AI.Discovery/src/Customization/DeleteUntilGoneOperation.cs) |
| Device Update | POST, endpoint-relative op-location | Java | polling location resolution, final-state-via | 🔴 A / 🟡 B | [Java OperationResourcePollingStrategyWithEndpoint.java](https://github.com/Azure/azure-sdk-for-java/blob/main/sdk/deviceupdate/azure-iot-deviceupdate/src/main/java/com/azure/iot/deviceupdate/implementation/OperationResourcePollingStrategyWithEndpoint.java) |
| App Configuration (snapshot) | CU (PUT), **standard polling** | C#, Go, JS | typed final-result only (*thin*) | 🟡 B | [C# CreateSnapshotOperation.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/appconfiguration/Azure.Data.AppConfiguration/src/CreateSnapshotOperation.cs) · [Go client.go](https://github.com/Azure/azure-sdk-for-go/blob/main/sdk/data/azappconfig/client.go) |
| DevCenter | CU + DEL, **standard polling** | C# | typed final-result only (*thin*) | 🟡 B | [C# DevBoxesClient.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/devcenter/Azure.Developer.DevCenter/src/Custom/DevBoxesClient.cs) |
| Batch (job/pool/node lifecycle) | CUSTOM (DEL + RPC POST, ~14 ops) | Py, C#, JS | polling mechanism (linked GET), status/terminal (`state`), 404 | 🔴 A | [Py `_operations/_polling.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/batch/azure-batch/azure/batch/_operations/_polling.py) · [C# LongRunningOperations/](https://github.com/Azure/azure-sdk-for-net/tree/main/sdk/batch/Azure.Compute.Batch/src/Custom/LongRunningOperations) · [JS customization/api/lroPoller.ts](https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/batch/batch/src/customization/api/lroPoller.ts) |
| Load Testing (test-run / validation) | CUSTOM (PUT then poll body `status`) | Py, C#, Java, JS | polling location, status/terminal, cancellation | 🔴 A | [Py `_operations/_patch.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/loadtesting/azure-developer-loadtesting/azure/developer/loadtesting/_operations/_patch.py) · [C# TestRunResultOperation.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/loadtestservice/Azure.Developer.LoadTesting/src/TestRunResultOperation.cs) · [JS getTestRunCompletionPoller.ts](https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/loadtesting/load-testing-rest/src/getTestRunCompletionPoller.ts) |
| Confidential Ledger / CodeTransparency | CUSTOM (CCF commit-status) | Py, C# | polling location, status/terminal (CBOR), error, 404-tolerance, id-swap | 🔴 A | [Py `_operations/_patch.py`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/_operations/_patch.py) · [C# PostLedgerEntryOperation.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/PostLedgerEntryOperation.cs) · [C# CreateEntryOperation.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CreateEntryOperation.cs) |
| Storage — Blob copy | CUSTOM (copy) | C#, JS | polling (header `copyId`), status (`copyStatus`), cancel, progress, error | 🔴 A | [C# CopyFromUriOperation.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/storage/Azure.Storage.Blobs/src/Models/CopyFromUriOperation.cs) · [JS BlobStartCopyFromUrlPoller.ts](https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/storage/storage-blob/src/pollers/BlobStartCopyFromUrlPoller.ts) |

## Root-cause buckets

Every handwritten customization found in the audit gets sorted into three buckets, and can be a combination of multiple buckets:

- **Bucket A — TSP gap.** No way to express in TSP today. Requires new / extended decorators or `LroMetadata` fields. Will require more extensive design usually, and will try to put in `@azure-tools/typespec-azure-core` when possible. Will fallback to TCGC decorators if we determine a library's need is not enough of a core need. **Dominant in the audit:** two families drive most hand-written volume — **(a)** "poll a *different* GET's body enum / until 404" state machines (Batch, Load Testing, Confidential Ledger, KeyVault delete-recover & cert-create, blob copy) and **(b)** **LRO + paging** (Translation, TextAnalytics, Conversations), neither expressible in today's `Azure.Core` LRO metamodel — plus cancellation-of-a-running-op. (Non-standard / renamed *terminal-state values*, once assumed to need customization, are actually already expressible — see Bucket B.)
- **Bucket B — Emitter gap.** Either TSP can already express it and the emitter simply isn't consuming it, or TSP will express it once Bucket A is landed, and the emitter will still need to be updated to read it. Pure per-language work, and will require a lot of coordination amongst language architects. **A customization (or client-core path) that exists *only because the emitter falls back to inspecting the raw response* is the strongest Bucket-B signal** — the metadata is (or will be) available; the emitter should consume it instead of sniffing. **These are the fastest wins** — the metadata is already (or nearly) available; the emitter just isn't reading it: analyze-family poller metadata (`operation_id`/`usage`/`details`, including the Java Content Understanding build-time patch to the generated polling strategy), typed-LRO convenience (App Config, DevCenter), and header/relative-URL wiring (KeyVault Admin, DeviceUpdate). A survey of `@lro*` decorator usage across `azure-rest-api-specs` sharpens this: **non-standard / renamed terminal-state values are already declared in-spec** via `@lroStatus` + `@lroSucceeded`/`@lroFailed`/`@lroCanceled` (~19 data-plane spec files — Document Intelligence, OpenAI Inference, Face, the Language family, Confidential Ledger, Radiology Insights), and **result-at-a-non-standard-property** is expressible via `@lroResult` (only Document Intelligence uses it today). So an SDK patch that *only* remaps terminal states or relocates the final result is Bucket B — the emitter/client-core should consume the declared metadata rather than hardcode it.
- **Bucket C — `getLroMetadata` gap** This bucket is not just "find bugs in `getLroMetadata`" — it's also **re-splitting what `getLroMetadata` returns between `@azure-tools/typespec-azure-core` (service description) and TCGC (client concerns).** Two kinds of work:
- **Correctness.** `getLroMetadata` / TCGC returns something incomplete or wrong for certain patterns (P0 to fix in TCGC / Azure.Core). *Under-counted in the audit* — only two clear hits surfaced (JS KeyVault-admin final-GET workaround #32142; RadiologyInsights nested-result) because custom pollers tend to *bypass* `getLroMetadata` rather than patch it; Phase 3 should force-derive from it to surface latent bugs. One concrete, countable cleanup the decorator survey exposes: several specs (Document Intelligence, OpenAI Inference, Face) add `@lroSucceeded`/`@lroFailed` *solely* to absorb **casing** differences from the guideline's canonical terminal values — a case-insensitive terminal-state match (in the `Azure.Core` template or `getLroMetadata`) would let those specs delete the decorators outright.
- **Layering.** `getLroMetadata` currently entangles two things. Roughly half of what it returns describes the *service's wire protocol* (objective, one right answer, belongs with the spec); the other half encodes *client-shaping decisions* (what the SDK should hand back, how the operation feels to a caller) that are properly TCGC's. Cleanly separating service description from client concerns is a structural goal of this effort, not a tidy-up.

| `getLroMetadata` field | Layer | Rationale |
| --- | --- | --- |
| `polling` (status field + terminal values) | **Service description** — `@azure-tools/typespec-azure-core` | Objective fact about how the service reports progress on the wire. |
| `final-state-via` | **Service description** | Objective fact about where the terminal result lives. |
| `statusMonitor` (envelope shape) | **Service description** | The protocol's status-monitor type. |
| `logicalResponse` / `logicalPath` — *what to return once the LRO completes* | **Client concern — TCGC (`SdkLroServiceMetadata`)** | A client-shaping choice (return the resource? the status monitor? a typed convenience?), and exactly the dimension that drives most of the audited customizations. |
| `final` (how to resolve / project the final result) | **Client concern — TCGC** | Depends on target-language idioms and desired client surface, not on the wire. |

**Direction:** the service-description half stays in / moves toward `@azure-tools/typespec-azure-core`; the client-shaping half (led by "what gets returned on completion") is owned by TCGC's `SdkLroServiceMetadata`, so emitters consume a single client-facing view and language architects can tune the returned shape without touching spec-layer semantics. This also sharpens the buckets: a **Bucket A** gap in the service-description half is a spec/protocol change, whereas a client-concern gap is a TCGC change — different owners, different review bars — and it keeps client preferences out of the shared spec vocabulary.

Note: A customization tagged A+B should be tracked as two linked work items — the schema change and the consuming change — since they'll usually land in different PRs, by different owners, on different timelines.

## When to remove vs. when to build

Spec confidence gates exactly one thing — **when it's safe to remove an override / response-inspection**, not whether to build the capability. *Building* (Bucket A expressibility, Bucket B emitter consumption, Bucket C correctness) proceeds regardless of confidence. Whether a pattern is worth standardizing at all is a separate question — see the **Standards vs. customization** section below.

The taxonomy assumes a service's TSP matches real wire behavior. That holds for **greenfield** services (authored in TSP, usually team-validated) but not always for **brownfield** ones (migrated from Swagger, often not re-verified against the live service — skews mgmt-plane, especially the legacy-Swagger migration).

- **Bucket D — Spec confidence.** 🟩 verified (greenfield/data-plane, or brownfield shipping a customization we trust) · 🟧 migrated, unverified · 🟥 known inaccurate.

**Gating rule.** 🟩 → remove the override and derive from `getLroMetadata` with no response-inspection. 🟧/🟥 → still build the capability, but keep the existing customization behind the fallback flag until Phase 3b verification confirms accuracy — removing a working crutch on an unverified spec trades a known-working customization for a plausibly-broken one.

**Empirically, brownfield is a later concern** — every in-scope Phase-1 customization came back 🟩 greenfield, so brownfield/mgmt-plane is explicitly deferred beyond the initial burn-down rather than driving it. The decorator survey corroborates this from the spec side: every mgmt-plane LRO spec uses **only** `@lroStatus` (custom status values), whereas the richer `@pollingOperation` / `@finalOperation` / `@lroResult` usage is concentrated in data-plane services.

## Standards vs. customization: when to standardize vs. accept

"Eliminate" is bounded by ROI — not every customization should be designed away. Add a standard (new / extended decorator, `LroMetadata` field, or emitter support) only when a pattern passes most of these tests; otherwise mark it **Accepted** and record why.

| Test | Standardize when… | Lean "Accept" when… |
| --- | --- | --- |
| **Recurrence** | pattern appears in ≥2–3 services, or is a named Azure REST API guideline shape | truly one-off to a single service |
| **Bounded expressibility** | modelable as a bounded extension (new enum value / field on existing metadata) | needs a brand-new metamodel concept / open-ended semantics |
| **Cross-language leverage** | closing it removes customization in ≥2 emitters | only one language even has the customization (likely an emitter quirk) |
| **Blast radius** | doesn't destabilize core `Azure.Core` semantics many services depend on | requires risky changes to shared polling contracts |
| **Behavioral stability** | the service's LRO shape is stable | still in flux |

**Applying it to the Phase-1 audit:**
- **Standardize (Addressable):** LRO + paging (Translation, TextAnalytics, Conversations — 3 services); poll-a-linked-GET's-status-field (Batch, Load Testing, Confidential Ledger); poll-until-404 soft-delete (KeyVault keys / secrets / certs — 4 packages × 4 languages); analyze-family poller metadata (Doc Intelligence, Content Understanding — Java already generates it); `provisioningState`-as-terminal and endpoint-relative `Operation-Location` (general shapes); typed-LRO convenience (App Config, DevCenter).
- **Accept (waive, with rationale):** Storage blob copy (bespoke `copyId` / `copyStatus` wire model, single-family, deep); KeyVault Security Domain (initial-response result + non-standard `"Success"` literal + `--no-wait`, one service); Confidential Ledger CBOR body + txn-id swap + replication-lag 404-tolerance (CCF-specific); the KeyVault certificate *pending-certificate* monitor (KV-specific status resource + `issuer=="Unknown"` short-circuit).

The Accepted set should stay **small and explicitly justified** — the goal is that every *remaining* customization is a deliberate, reviewed choice, not an accident of tooling gaps.

## Approach / Phases

| Phase | Description | Output |
| --- | --- | --- |
| 0 | Define target state, taxonomy, and buckets (this doc) | This proposal |
| 1 | Audit: inventory all handwritten LRO customizations, per language, mapped to taxonomy | Customization matrix |
| 2 | Sort findings into Buckets A/B/C, prioritize | Prioritized backlog |
| 3 | Validate `getLroMetadata` correctness against known-tricky patterns **and re-split its fields between `typespec-azure-core` (service description) and TCGC (client concerns)** (Bucket C) | Bug list + fixes; agreed field-ownership split |
|3b| Spec verification to confirm LRO behavior matches real service behavior. Two paths: recording-derived where recorded traces exist; manual would require interfacing with service team | Updated confidence tiers, spec correction |
| 4 | Close Bucket A gaps (decorator/schema RFCs) and Bucket B gaps (per-language refactors) | Merged changes |
| 5 | Migrate one emitter end-to-end as proof of concept (proposed: `http-client-python`), then roll out to remaining languages | Regeneration diffs, burn-down of customization count |
| 6 | Add CI/lint guardrails preventing new header-sniffing LRO logic from being reintroduced | Guardrail tooling |

## Success metrics & measurable outcomes

**Primary — _addressable_ handwritten LRO customization count → 0.** Track handwritten LRO customizations per language, split into **Addressable** (a fixable A/B/C gap) and **Accepted** (deliberately waived — see the rubric). Only the Addressable population is targeted to zero; the Accepted set is curated and reported separately, not counted as failure. "Done" for an addressable site: delete the override, regenerate, and the generated output is unchanged (or an intentional, reviewed improvement) — verified via regeneration diff.

**Secondary (stretch) — response-inspection sites → 0.** Once overrides are off, count the sites in generated + client-core polling code that still inspect raw responses (header-sniffing, status re-derivation) to drive polling/completion, and trend that to zero so behavior is fully spec-derived. Sequenced *after* the primary burn-down, not ahead of it.

**Measurable outcomes — the case for the investment:**

_Quality improved_
- **Cross-language behavioral consistency** — number of services whose LRO polling / terminal / final-result behavior *differs* across the five languages, trending down. Hand-written pollers drift language-to-language; spec-derived output can't. This is the headline quality claim.
- **Bucket-C correctness fixes** — count of `getLroMetadata` / TCGC bugs found and fixed; each benefits every consumer at once.
- **Regeneration safety** — % of packages in the addressable set that regenerate LRO code with zero manual re-patching → 100% (ties directly to the Phase 6 guardrail).

_Effort saved_
- **Hand-written LRO LOC deleted**, per language / service (baseline captured in Phase 1 — e.g. Batch's Python `_polling.py` alone is ~1k lines across its 14 pollers).
- **Customization files / sites removed.**
- **New-LRO-service onboarding time** — per-emitter hand-poller work today (hours) → ~0; survey emitter owners for a before/after estimate.

## Risks

| Risk | Mitigation |
| --- | --- |
| Bucket A changes require cross-team/cross-language agreement and could stall | Scope RFCs narrowly per gap; reuse the existing decorator-proposal pattern already used for TCGC work |
| Metadata correctness bugs (Bucket C) discovered late, after emitters already migrated | Sequence Phase 3 before Phase 4/5; build a "known-tricky" regression test corpus from Phase 1 findings |
| Migrating a brownfield/mgmt-plane operation fully onto spec-derived behavior without verification introduces a regression the old customization was silently protecting against | Gate Bucket A/B closure for 🟧/🟥-tier operations behind the explicit fallback flag until Phase 3b verification confirms accuracy; never remove a working customization based on tier alone |
| Audit undercounts customizations that are silent/undocumented in-code, not in a clearly labeled override file | Grep-based discovery plus targeted service-by-service spot checks, not just override-directory scans |
| Per-language teams deprioritize their Bucket B refactor | Land the Python proof-of-concept first to demonstrate value and provide a template PR other languages can mirror |

## Stakeholders

- @iscai-msft — audit, taxonomy, Bucket A/C proposals, Python proof-of-concept
- @timotheeguerin, @markcowl, @catalinaperalta, @tadelesh, @lirenhe — decorator/schema review (overlaps with unbranded TCGC scoping work)
- @lmazuel — sign-off
- **Per-language emitter owners** — Bucket B refactors for their language
- Brownfield/mgmt-plane service teams — Bucket D verification (owner TBD — see open questions)

## Open questions

- [ ] Do we want a hard deadline/quarter target, or treat this as an ongoing burn-down tracked via the customization-count metric?
- [ ] Which language after Python for the second migration — pick based on customization volume or team availability? I'm thinking Java because they seem to consume the most of `getLroMetadata` currently
- [ ] What recording coverage do we actually have across brownfield/mgmt-plane services — is it broad enough to meaningfully move the needle on Bucket D, or mostly happy-path/limited to a subset of services?
- [ ] Who owns / coordinates spec verification (phase 3b)?
- [ ] Should the fallback flag be a TCGC-level decorator or an emitter-level config? Leaning decorator so we can be more prescriptive per-operation

## Tracking

I'll be updating this issue, and creating more concrete GitHub issues and linking them here as my investigation continues.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.