# Migrating a core service to NemoClient

A reusable playbook for moving a NeMo Platform service off the Stainless-generated
`NeMoPlatform` SDK and onto the typed `NemoClient` HTTP client.

Tracking issue: **AIRCORE-827** (parent). First reference migration: **Files/Filesets**
(PR #429, follow-ups #539/#576). This doc was written while doing **Secrets**
(AIRCORE-873) as the second migration; where it says "for Secrets" treat that as a
worked example of the general steps.

> **Status of shared infra:** stable. The core client (`nemo_platform_plugin/client/`)
> — decorators, `send()`, pagination, retry, errors, auth/OIDC, the
> `client_from_platform` adapter — already exists and is exercised by Files. Per-service
> work is almost entirely `types.py` + `endpoints.py` + `client.py` + tests.

---

## 1. What "migrate to NemoClient" actually means

| | Stainless SDK (`NeMoPlatform`) | NemoClient |
| --- | --- | --- |
| Where it comes from | Generated by Stainless from `openapi.yaml`, vendored under `sdk/python/nemo-platform/` | Hand-written typed Python in `nemo_platform_plugin/<service>/` |
| Source of truth | The OpenAPI spec (which is itself generated from the FastAPI routes) | `endpoints.py` + `types.py`, imported by **both** client and (eventually) server |
| Call style | `sdk.secrets.create(workspace=..., name=..., value=...)` → entity | `client.create_secret(workspace=..., body=CreateSecretRequest(...))` → `NemoResponse`; `.data()` for the entity, `.items()` for a list |
| Types | Generated params/response classes under `nemo_platform.types.secrets.*` | Plain Pydantic models you own |
| Errors | `nemo_platform.NotFoundError`, `ConflictError`, … | `nemo_platform_plugin.client.errors.*` (same names, different module) |
| Regeneration | `make update-sdk` (Stainless round-trip) | none — it's just code |

The end-goal of AIRCORE-827 is to delete the Stainless dependency once every service has a
NemoClient. Each per-service ticket (873–878) builds one service's typed client; sibling
tickets wire the job factory/provider (AIRCORE-883), first-class auth (AIRCORE-828),
service-to-service URL routing (AIRCORE-854), and server-side route generation (AIRCORE-882).

### Sequencing: core services first, then plugins

The migration is phased on purpose:

1. **Migrate all core services** (Secrets, Files, Jobs, Entities, Models, …) to typed clients.
2. Once every service a plugin talks to has a NemoClient, **plugins no longer need the Stainless
   SDK** — their SDK dependency can be dropped.
3. **Plugins then adopt this same system**, defining their own `endpoints.py` / `types.py` /
   `client.py` for their surfaces instead of relying on generated code.

So the core-service work here is the foundation that unblocks the plugin side.

---

## 2. The core infrastructure (already built — you consume it)

All under `packages/nemo_platform_plugin/src/nemo_platform_plugin/client/` (no `__init__.py`;
import by fully-qualified module path). Documentation is the top-of-file docstrings.

| File | What it gives you |
| --- | --- |
| `types.py` | Marker types (`BinaryContent`, `Stream[T]`, `Paginated[T, Strategy]`), `PreparedRequest`, `OffsetPagination` (matches the standard `{"data":[...],"pagination":{...}}` envelope), `RetryPolicy`, `ConflictResolver`, `RESERVED_PARAM_NAMES`, `BLESSED_CLIENT_PARAMS` (`exist_ok`). |
| `endpoint.py` | `@get/@post/@put/@patch/@delete` decorators. They read the path template + your signature and build a `PreparedRequest`; **validate params at import time** (unknown param → `TypeError`). |
| `method.py` | `method(endpoint_fn)` descriptor — binds a free endpoint function onto a client class as a real instance method, dispatching sync vs async, with the correct return type per marker. |
| `client.py` | `NemoClient` / `AsyncNemoClient` and `BaseNemoClient`. `send()` resolves the path (fills `workspace` default), injects auth, applies retry, and dispatches by return marker. Also `with_options`, `from_client`, `from_config`. |
| `response.py` | `NemoResponse[T]` (`.data()`), `NemoBinaryResponse` (`.read()`/`.stream()`), `NemoStreamResponse[T]`, `NemoPaginatedResponse[T]` (`.items()`/`.pages()`/`.page()`). |
| `errors.py` | `NemoHTTPError` + status subclasses (`NotFoundError`=404, `ConflictError`=409, `UnprocessableEntityError`=422, …). `raise_for_status()`. |
| `auth.py` / `oidc.py` / `oidc_factory.py` | `TokenProvider` protocols, `StaticToken`, `OIDCTokenProvider`, config-file-backed provider caching. |
| `adapter.py` | `client_from_platform(platform, ClientCls)` — build a NemoClient that **shares** an existing `NeMoPlatform`'s httpx transport (auth/pooling carry over). The migration bridge. |

### Parameter conventions in `endpoints.py` (enforced at import time)

Every parameter of an endpoint stub must be one of:

- a **path placeholder** — a name that appears as `{name}` in the path template;
- `body` — a Pydantic `BaseModel`, serialized to JSON via `model_dump_json(exclude_unset=True)`;
- `content` — raw bytes / (async) iterable of bytes, sent as `application/octet-stream`;
- `query_params` — a `TypedDict` (or dict) of query parameters;
- a **blessed client option** (currently only `exist_ok: bool`), stripped from the wire.

`workspace` is conventionally `str | None = None` — a path placeholder the client fills from its
default when omitted. Anything else raises `TypeError` when the module is imported.

### Return markers drive `send()` and `method()`

| Endpoint return annotation | `send()` returns | Unwrap with |
| --- | --- | --- |
| a Pydantic `BaseModel` | `NemoResponse[T]` | `.data()` → `T` |
| `None` | `NemoResponse[None]` | `.data()` → `None` |
| `Paginated[T]` | `NemoPaginatedResponse[T]` | `.items()` / `.pages()` / `.page()` |
| `BinaryContent` | `NemoBinaryResponse` | `.read()` / `.stream()` |
| `Stream[T]` | `NemoStreamResponse[T]` | `.stream()` |

---

## 3. The per-service template (what you write)

Mirror `packages/nemo_platform_plugin/src/nemo_platform_plugin/files/`. Create
`packages/nemo_platform_plugin/src/nemo_platform_plugin/<service>/` with three files.

### 3a. `types.py` — request/response models

Plain Pydantic, owned by the plugin package, and the **single source of truth**: the client
imports these, and the server's `schemas.py` should re-export them (see 3e). Query params as
`TypedDict(total=False)` with `NotRequired`.

The models live in `nemo_platform_plugin/<service>/`, so they inherit that package's dependency
boundary — **don't import `nmp_common`** (or other server-only packages) from `types.py`. The
`files.types` reference deliberately uses `nemo_platform_plugin.schema.Page` instead of
`nmp.common.api.common.Page`. If you need a server constant (e.g. a name-validation regex), inline
it with a comment pointing at the original rather than adding the dependency.

> **`SecretStr` (and any masked field) will silently send `"**********"`.**
> `model_dump_json()` on a `SecretStr` field emits the mask, not the value — so a request body
> built from it would ship the mask to the server. If a request field must stay `SecretStr` (for
> repr/log masking), add a JSON serializer that emits the real value:
> ```python
> value: SecretStr
>
> @field_serializer("value", when_used="json")
> def _serialize_value(self, value: SecretStr) -> str:
>     return value.get_secret_value()
> ```
> Assert the wire bytes in a test (`test_create_secret_serializes_real_value`) — this is easy to
> miss because the model looks correct. Also note: at call sites that build the request from a plain
> `str`, construct the field explicitly (`CreateXRequest(value=SecretStr(v))`) — `ty` wants
> `SecretStr`, not `str`, and won't coerce.

### 3b. `endpoints.py` — the HTTP contract

Free functions, decorated + `@abstractmethod` + `...` body. One function per route. Full path
including the `/apis/<service>` gateway prefix. Use the `get_on_conflict` + `exist_ok` pattern for
create-if-not-exists (see Files' `create_fileset`).

The decorator's `body` path only serializes a `BaseModel` — there's **no raw-dict body path**. If an
endpoint sends a bare JSON object (`dict[str, Any]`), wrap it in a `RootModel`
(`class XUpdate(RootModel[dict[str, Any]])`) for the client `body`; the server can keep its `dict`
alias. (From the Jobs migration — `update_job_status_details`.)

### 3c. `client.py` — bind endpoints onto clients

```python
from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient
from nemo_platform_plugin.client.method import method
from nemo_platform_plugin.<service> import endpoints

class _ServiceMethods:
    create_x = method(endpoints.create_x)
    list_x   = method(endpoints.list_x)
    # ...one line per endpoint...

class ServiceClient(_ServiceMethods, NemoClient): ...
class AsyncServiceClient(_ServiceMethods, AsyncNemoClient): ...
```

### 3d. Tests

Mirror `packages/nemo_platform_plugin/tests/`:

- `tests/<service>/test_endpoints.py` — assert `PreparedRequest` shape (`.method`, `.path_template`,
  `.path_params`, `.content`, `.content_type`, `.response_type` identity, `.query_params`,
  workspace-optional behavior). No network.
- `tests/client/`-style — drive `send()` with a `MagicMock(spec=httpx.Client)` /
  `AsyncMock(spec=httpx.AsyncClient)` injected as `http_client=`, or use `respx`.

Also run the service's own SDK integration test (e.g. `test_<service>_sdk.py`) after repointing
its fixture (3e) — it drives the real FastAPI routes in-memory and is the best end-to-end check
that path resolution, pagination, error mapping, and body serialization all line up.

### 3e. Point the server at the shared types (single source of truth)

Rewrite the service's `api/v2/.../schemas.py` to **re-export** the plugin `types.py` models instead
of redefining them, so client and server can't drift. Keep only server-only concerns (e.g. a
`from_entity` classmethod) as a thin subclass:

```python
from nemo_platform_plugin.<service>.types import (
    CreateXRequest as CreateXRequest,
    XResponse as _XResponse,
)

class XResponse(_XResponse):
    @classmethod
    def from_entity(cls, entity: X) -> "XResponse":
        return cls(name=entity.name, ...)
```

This is exactly what `services/core/files/.../filesets/schemas.py` does. The service can import
`nemo_platform_plugin` without adding an explicit dependency (it's available through the workspace),
so no `pyproject.toml` change is needed — but verify the server still imports (`uv run python -c
"import nmp.core.<svc>.service"`) and that any field validators / `from_entity` behavior survives.

#### The data-vs-behavior split (when moving types is hard)

Secrets was easy — its types are pure data. Richer services (Jobs, Models) hit a wall: moving a type
to the plugin drags **runtime deps** (kubernetes, docker) or **server modules** with it, because the
*behavior* lives on the model. The rule: **the plugin holds pure-pydantic wire shapes; the server
subclasses them to re-add behavior.** E.g. `nemo_platform_plugin.<svc>.types` has
`class KubernetesVolume(BaseModel)` (data only); the server's `common.py` has
`class KubernetesVolume(PluginKubernetesVolume)` with `to_k8s()`. No kubernetes/docker import enters
the plugin. Watch for these (all from the Jobs migration, AIRCORE-874 / PR #585):

- **Behavior that pulls deps** — methods that build `kubernetes.client.V1*` objects, `to_k8s()`,
  validators that import auth/config server modules. These stay on the *server subclass*, not the
  plugin model.
- **Import-time env defaults stay server-side.** A field defaulting to `os.getenv(...)` at import
  (e.g. a docker network name set by quickstart/e2e) must NOT be inlined as a literal in the plugin —
  that silently drops runtime configurability. Plugin gets a plain literal default; the server
  subclass restores the env-driven one.
- **Nested field types silently defeat the split.** Subclassing `KubernetesVolume` to add `to_k8s()`
  isn't enough — a field `additional_volumes: list[KubernetesVolume]` on some *other* config still
  resolves to the plugin (behavior-less) type, so `.to_k8s()` `AttributeError`s at runtime. The server
  subclass must **re-type the nested fields too**, cascading all the way up the config tree. Only the
  service's own backend tests catch this; the type checker won't.
- **Same-named pydantic classes are NOT interchangeable.** A field typed as the *plugin's* `AuthContext`
  rejects a *server* `AuthContext` instance at construction — pydantic treats identically-named classes
  as distinct types. And a re-export line (`AuthContext as AuthContext`) in the server schemas module
  can silently **shadow** the server import so a subclass field resolves back to the plugin type. Fix:
  the server subclass overrides the field with the server type, and you do **not** re-export the plugin
  version from the server schemas module.
- **Some types are genuinely server-only — don't move them.** A list *filter* that subclasses
  `nmp.common.entities.Filter` (entity-store field translation) or a response that wraps raw entity
  instances cannot live in the plugin (`Field 'source' does not exist on model DBEntity`). Leave them
  server-side; the client uses query-param strings and DTO equivalents instead. Not everything belongs
  in the leaf node.

---

## 4. Migrating the consumers

Building the client is the easy part. The service's callers are spread across the repo and must
switch off `sdk.<service>.*`.

### The goal: stop *using* Stainless, not force every consumer onto NemoClient

The real objective is to **remove Stainless SDK usage** for this service so the Stainless dependency
can eventually be dropped — i.e. **no code calls `sdk.<service>.*`** (the Stainless subresource) any
more. Concretely that means migrating every real call site to the typed client. It does **not** mean:
- re-plumbing how the SDK is constructed or passed around — the DI seams, factories, and
  `Depends(...)` that hand out `NeMoPlatform` keep doing exactly that (swapping those to hand out typed
  clients is the separate **AIRCORE-883** effort); nor
- forcing consumers that never touched Stainless onto NemoClient. A raw-HTTP caller
  (`subprocess_runtime.py` builds `/apis/secrets/...` URLs itself, no SDK) isn't Stainless usage — it
  can stay as-is. Mock-based tests that stub `mock.secrets.access` aren't Stainless *usage* either,
  but once the code under test routes through `client_from_platform`, those stubs are dead — remove
  them (nothing asserts on them) so no stale Stainless-shaped surface lingers.

Be exhaustive when inventorying: production **and** test call sites, in `services/*`, `packages/*`,
`plugins/*`, and top-level `tests/*` (e2e/`agentic-use`), plus test-*helpers* and *harnesses*. Tests
that call the real service pass whether or not they're migrated (the service is unchanged), so they
won't show up as CI failures — grep for `.secrets.` across the whole tree, don't rely on red tests.

The bridge that makes this work at every call site is **`client_from_platform`**: given the
`NeMoPlatform` a consumer already has, it builds a typed client that **shares the same httpx
transport** (so auth headers, connection pooling, base URL all carry over). You adapt-and-call at
the leaf; nothing upstream changes.

```python
# before — flat kwargs on the Stainless subresource
resp = await sdk.secrets.access(name=name, workspace=workspace)
value = resp.value

# after — same `sdk`, adapted to the typed client at the call site
from nemo_platform_plugin.client.adapter import client_from_platform
from nemo_platform_plugin.secrets.client import AsyncSecretsClient

secrets = client_from_platform(sdk, AsyncSecretsClient)
value = (await secrets.access_secret(name=name, workspace=workspace)).data().value
```

A file that uses `sdk.secrets.*` **and** `sdk.models.*` just adapts the secrets calls now and leaves
the `sdk.models.*` calls untouched until Models migrates — no dual-ownership problem, because both
share one underlying transport and the `sdk` is still the thing being passed around.

**Deeper option for classes you own that hold an SDK to make their own calls:** instead of adapting
inside every method, change the constructor to take the typed client directly — Files did this,
turning `FilesetFileSystem(sdk=...)` into `FilesetFileSystem(client=FilesClient)`, and callers pass
`client_from_platform(sdk, FilesClient)`. Cleaner end-state, but it ripples to every caller, so weigh
it against the leaf-adapter. A middle ground Files also used: keep the old facade
(`sdk.files` / a `FilesResource`) but let it accept an optional `files_client=` and delegate to the
typed client internally — so existing callers keep working while Stainless drops out underneath.

**Scope discipline: change only what wraps a `sdk.<service>.*` call you're replacing.** The `sdk`
construction/plumbing, and every other Stainless import, call, and `except` in the file, stay exactly
as they are. Until the whole codebase is on NemoClient, files legitimately keep importing Stainless
types for their other CRUD — don't "clean those up" in a single-service PR.

> **Before deferring a consumer, verify it's actually blocked — don't assume a NemoClient gap.**
> A hairy consumer (RBAC tests with per-principal auth, on-behalf-of delegation, multi-service setup)
> can *look* like the client can't handle it. In the Secrets migration the RBAC integration test was
> initially deferred as "needs first-class auth" — that was wrong. Per-principal / on-behalf-of auth
> is just headers (`as_user(sdk, email)` → `with_options(set_default_headers=...)`), and
> `client_from_platform` copies them, so it migrated cleanly. The real coupling was that the test
> *also* drives `sdk.workspaces.*` for setup — which stays Stainless (out of scope), not blocked.
> Lesson: the blocker is almost always test-helper / multi-service coupling, not a client limitation.
> Check what the client can actually do before writing something off.

At each call site:

- Replace `sdk.<service>.<op>(...)` with `client_from_platform(sdk, <Client>).<method>(...).data()`
  (or `.items()` / `.read()` / `.stream()` per the return marker). Adapt once per scope and reuse if
  there are several calls. Note the ergonomic shift: the old SDK took flat kwargs
  (`sdk.secrets.create(name=..., value=...)`); the typed client takes a request model
  (`create_secret(body=CreateXRequest(...))`).
- For the secrets call **you are changing**, catch `nemo_platform_plugin.client.errors.*` instead of
  the Stainless exceptions. Subclass names match (`NotFoundError`, `ConflictError`,
  `UnprocessableEntityError`, …). **But the error attribute differs:** Stainless used `e.message`;
  `NemoHTTPError` uses `e.detail` (plus `e.status_code`, `e.body`).
  - **Scope: only the secrets call sites.** Leave every other Stainless import and `except` in the
    file untouched — they belong to calls you're *not* migrating. Until the whole codebase is on
    NemoClient, files will keep importing Stainless errors for their other CRUD (models, entities,
    files, inference, …); that's expected and out of scope for a Secrets PR. Don't repoint a
    module-level `NotFoundError` import wholesale, because the file's non-secrets calls still raise
    the Stainless one. Instead, add the typed error under a distinct alias and use it *only* in the
    `except` for the secrets call. Use the **`Client`-prefixed** convention
    (`ClientNotFoundError` / `ClientPermissionDeniedError`) so it's unambiguous which variant it is —
    apply the alias even in files whose only error use is the secrets call, for consistency:
    ```python
    from nemo_platform import NotFoundError                      # unchanged — still used elsewhere
    from nemo_platform_plugin.client.errors import NotFoundError as ClientNotFoundError
    ...
    try:
        await secrets.get_secret(name=name, workspace=ws)
    except ClientNotFoundError: ...
    ```
    The rule of thumb: touch an import or `except` only when it wraps a `sdk.secrets.*` call you're
    replacing.
- Repoint list iteration: the old SDK returned `.data`; the typed client returns a paginated
  response — use `.items()` to iterate across pages or `.page().items` for one page.
- **Fix the consumer's tests too — this is the bulk of the work.** Tests that mocked
  `sdk.secrets.<op>` break, because the call now goes through `client_from_platform(sdk, <Client>)`.
  Patch the adapter in the consumer's module so the helper gets a controllable mock client:
  ```python
  mock_secrets = MagicMock()
  mock_secrets.access_secret = AsyncMock(return_value=_resp)   # _resp.data().value = "..."
  with patch("nmp.core.<svc>.<module>.client_from_platform", return_value=mock_secrets): ...
  ```
  Remember the response is wrapped: assert against `mock.access_secret.return_value.data().value`,
  and the call is now kwargs (`access_secret(name=..., workspace=...)`), not positional. To simulate
  a 404, set `side_effect = NotFoundError(MagicMock(status_code=404))` (a `MagicMock` is a fine
  stand-in for the `http_response`).
- Where a test constructs its own `NeMoPlatform(base_url=..., http_client=...)` as a real in-memory
  client, build the typed client directly instead (`SecretsClient(base_url=..., http_client=...)`).
- Delete the now-dead Stainless usage (and, once nothing imports it, the Stainless resource).

### The auto-generated CLI commands

The `nemo <svc> *` command group under `cli/commands/api/<svc>/` is generated by the CLI generator
(`tools/nemo-platform-sdk-tools/.../cli_generator/`) from the Stainless resource. Two ways a command
can be produced:
- **from the template** — the generator renders `client = state.get_client(); client.<svc>.<method>
  (**kwargs)`. This is the common case.
- **from an override** — for commands the template can't express (custom stdin handling, special
  flags), `cli_config.yaml` points at a hand-written `cli_generator/overrides/<svc>/<method>.py` that
  the generator injects verbatim.

**Hand-written** CLI code (use-case commands, `setup.py` helpers) migrates like any other consumer:
`client_from_platform(state.get_client(), <Client>)`. Do it in the PR.

**For the generated commands, check `cli_config.yaml` for the service's existing overrides.** How much
you can do in-PR depends on how many commands already have one:
- Editing an **existing** override to call `client_from_platform(sdk, <Client>).<method>(...)` is cheap
  — do it in the PR. (Files migrated its CLI this way; it happened to already have an override for every
  command it touched.)
- Creating a **net-new** override for a template-generated command, purely to swap the client, is a
  poor trade — the override becomes a hand-maintained command body that diverges from the template
  forever. Leave those to the generator fix. (Secrets is mostly this case, so its CLI is deferred.)

The durable answer for template-generated commands is teaching the *template* to emit typed-client
calls — **AIRCORE-893** — so they all migrate at once. (A per-service proxy shim mimicking the Stainless
resource was tried and reverted as the wrong shape; don't repeat it.)

When either the template or an override moves to the typed client, mind these output-shape points
(Files' overrides show the pattern):
- **List: replace `fetch_all_pages` / `warn_if_more_pages`, don't bridge them.** The typed
  `list_<x>()` returns a `NemoPaginatedResponse`; use `ListQueryParams(**kwargs)` + `list(response.
  items())` directly (Files dropped the Stainless `fetch_all_pages`/`warn_if_more_pages` helpers
  entirely). This is why the earlier "adapt the paginated response to `fetch_all_pages`" shim idea was
  wrong — you replace the helper, not adapt to it. (`cli/core/formatters.py::_extract_items_from_
  response` already invokes a `.items()` callable, so list *formatting* just works.)
- **Single-entity commands** (`create`/`get`/`access`/`update`) return a `NemoResponse` → unwrap with
  `.data()` before `format_output`.
- **Missing-workspace error** — the CLI keys its friendly exit-2 message off the Stainless
  `ValueError("Missing workspace argument")`; the typed client raises `"Missing path parameter
  'workspace' ..."`. If a migrated command can hit this, translate the message.

---

## 5. Worked example: Secrets (AIRCORE-873)

### Contract — 7 endpoints, 2 routers, served under `/apis/secrets`

Router mounting adds no prefix (`RouterConfig.prefix=""`); the `/apis/secrets` prefix is applied
by the gateway (`/apis/{service.name}`). In-app paths start at `/v2/...`.

**`secrets/` router → `SecretsClient` methods**

| Method | HTTP | Path (full) | Body | Query | Returns | Status |
| --- | --- | --- | --- | --- | --- | --- |
| `create_secret` | POST | `/apis/secrets/v2/workspaces/{workspace}/secrets` | `PlatformSecretCreateRequest` | — | `PlatformSecretResponse` | 201 |
| `list_secrets` | GET | `/apis/secrets/v2/workspaces/{workspace}/secrets` | — | `page`, `page_size` | `Paginated[PlatformSecretResponse]` | 200 |
| `get_secret` | GET | `/apis/secrets/v2/workspaces/{workspace}/secrets/{name}` | — | — | `PlatformSecretResponse` | 200 |
| `update_secret` | PATCH | `/apis/secrets/v2/workspaces/{workspace}/secrets/{name}` | `PlatformSecretUpdateRequest` | — | `PlatformSecretResponse` | 200 |
| `delete_secret` | DELETE | `/apis/secrets/v2/workspaces/{workspace}/secrets/{name}` | — | — | `None` | 204 |
| `access_secret` | GET | `/apis/secrets/v2/workspaces/{workspace}/secrets/{name}/access` | — | — | `PlatformSecretAccessResponse` | 200 |

**`admin/` router → `SecretsClient.admin` (or flat `rotate_encryption_keys`)**

| Method | HTTP | Path (full) | Body | Returns | Status |
| --- | --- | --- | --- | --- | --- |
| `rotate_encryption_keys` | POST | `/apis/secrets/v2/rotate-encryption-keys` | — | `PlatformSecretAdminRotationResponse` | 202 |

The models now live in `nemo_platform_plugin/secrets/types.py` as the single source of truth
(`PlatformSecretResponse`, `PlatformSecretCreateRequest` — `name` regex-validated inline,
`value: SecretStr` with a JSON serializer, non-empty — `PlatformSecretUpdateRequest`,
`PlatformSecretAccessResponse`, `PlatformSecretAdminRotationResponse`). The server's
`api/v2/secrets/schemas.py` re-exports them and subclasses `PlatformSecretResponse` only to keep
`from_entity`. See 3a/3e.

### Watch-outs specific to Secrets

- **Admin auth differs from CRUD.** The `secrets/` CRUD routes carry **no in-handler auth** —
  authorization is gateway-enforced via `static-authz.yaml` (perms `secrets.create/list/read/
  update/delete/access`, scopes `platform:*`/`secrets:*`). The `admin/` route enforces
  `secrets.rotate` **in code**, in the `system` workspace. The client itself carries auth headers
  the same way for both; just make sure the `admin` endpoint's path/scoping is preserved and
  covered by a test.
- **`access_secret` has an extra on-behalf-of check** (403 if the delegated principal lacks
  `secrets.read`). Behavior lives server-side; the client just needs to surface 403 as
  `PermissionDeniedError`.
- **Pagination, 2 refs.** (1) `list_secrets` — `page`/`page_size`, returns the standard `Page`
  envelope, so `Paginated[PlatformSecretResponse]` with the default `OffsetPagination` works
  as-is. (2) The admin rotation routine paginates internally server-side — **not** client-facing.
- **Workspace validation → 422.** Creating in a non-existent workspace surfaces as
  `UnprocessableEntityError` (message `"Workspace '{workspace}' does not exist"`) from the entity
  store, not from secrets code. Model 422 on create in tests.
- **Default NGC API key** is synthetically injected/guarded server-side (403 on
  create/update/delete of it; synthetic value on get/list/access). No client change; just surface
  403 as `PermissionDeniedError`.
- **`value` is a `SecretStr` → confirmed serialization gotcha.** Without a JSON serializer the
  request would ship `"**********"`. Fixed in `types.py` with `@field_serializer(..., when_used=
  "json")` returning `get_secret_value()`; covered by `test_create_secret_serializes_real_value`.
  See the boxed warning in 3a.

### How the general steps played out for Secrets

- **Contract** was small and pure-data, so §3a–3e went straight through: the models moved to
  `nemo_platform_plugin/secrets/types.py` and the server `schemas.py` re-exports them, subclassing only
  to keep `from_entity`. No data-vs-behavior split needed.
- **Consumers** ranged across the shapes §4 describes: production s2s callers (files/jobs/models/
  inference-gateway/data_designer) and hand-written CLI helpers via `client_from_platform` at the leaf;
  the RBAC integration suite via `client_from_platform(as_user(sdk, email), SecretsClient)` per
  principal (confirming per-principal/on-behalf-of auth is just headers the adapter carries); and a long
  tail of test setup/harness/e2e calls. Stale `mock.secrets.*` stubs were removed once the code routed
  through the adapter.
- **Left out** (per "stop using Stainless," not "force NemoClient on everything"): the generated
  `nemo secrets` CLI (mostly template-generated commands → AIRCORE-893, §4), a raw-HTTP caller that
  never used the SDK, and a few docstring examples.

The concrete per-file inventory lives in the PR description, not here — this doc is the reusable
playbook.

---

## 6. Generalized checklist (copy per service)

- [ ] **Scope the contract.** List every route (method, full `/apis/<svc>` path, path/query/body,
      response model, status, auth). Note pagination, binary/stream endpoints, privileged routers.
- [ ] **Write `types.py`** (models + `TypedDict` query params) as the source of truth. Watch masked
      fields (`SecretStr`) — add a JSON `field_serializer`. No `nmp_common` imports.
- [ ] **Write `endpoints.py`** (decorated `@abstractmethod` stubs; `get_on_conflict`/`exist_ok`
      where create-if-not-exists is wanted).
- [ ] **Write `client.py`** (`_Methods` mixin + sync/async subclasses).
- [ ] **Client tests** (`test_endpoints.py` shape asserts + `send()` with mocked httpx; assert a
      masked field serializes to its real value).
- [ ] **Point the server `schemas.py` at the shared types** (re-export; subclass only for
      `from_entity`). Verify the server still imports.
- [ ] **Inventory consumers** across `services/*`, `plugins/*`, `packages/*`, `tools/*`, `tests/*`,
      the CLI, and any raw `/apis/<svc>` HTTP.
- [ ] **Migrate every `sdk.<svc>.*` call site** via `client_from_platform(sdk, <Client>)` at the leaf
      (`client.<method>(...).data()`, catch `client.errors.*` — note `.detail` not `.message`,
      `.items()` not `.data`). The adapter handles the DI-injected consumers too, so almost nothing is
      genuinely blocked — see §4. If you *do* leave something out (raw-HTTP callers, the generated CLI),
      **list it explicitly** in the PR description; don't silently skip it.
- [ ] **Update the service's own test fixtures** to hand out the new client instead of
      `NeMoPlatform(...)`. Check for *local* fixtures in individual test files too (they override
      conftest). Mocked-secrets tests need re-wiring (patch `client_from_platform`, `.data()`-wrap).
- [ ] **If you touched `nemo_platform_ext`** (e.g. `setup.py` helpers, a use-case command), run
      `make vendor-nemo-platform-ext` — that package is vendored into `sdk/python/nemo-platform`, and
      the CLI runs from the vendored copy. Vendored `sdk/python` is ruff-excluded, so don't chase its
      pre-existing lint errors.
- [ ] **Preserve auth semantics** — privileged routers, on-behalf-of checks, scopes.
- [ ] **Run** `uv run --frozen ty check`, `uv run ruff check`, `make test-package PACKAGE=nemo_platform_plugin`,
      `make test-unit-<service>`, and the service's integration tests. **For `ty`, measure against a
      baseline, not zero** — services carry many pre-existing diagnostics (Jobs had ~115). Stash your
      change, capture the diagnostics, then diff categories (line numbers shift, so location diffs are
      noise). Goal: **0 net-new** errors.
- [ ] **Run the service's own backend/integration tests**, not just unit — the data-vs-behavior split
      (3e) fails in ways the type checker misses (a nested field resolving to the behavior-less plugin
      type only `AttributeError`s at runtime, caught by the k8s/docker backend tests).
- [ ] **Drop the Stainless resource** for this service once nothing imports it, and feed the pattern
      into AIRCORE-882 (server route gen). AIRCORE-883 (job factory hands out typed clients) is a
      *separate* cleanup — it removes the per-call-site adapter, it does **not** gate this migration.

---

## 7. Known gaps / cross-cutting tickets

- **AIRCORE-828** — first-class auth (standalone, not via the `client_from_platform` adapter).
- **AIRCORE-854** — service-to-service URL routing for NemoClient.
- **AIRCORE-882** — generate FastAPI routes from `endpoints.py` (the shared-contract payoff).
- **AIRCORE-883** — job client factory/provider hands out NemoClient instead of `NeMoPlatform`. This
  does **not** gate the per-service migrations — the `client_from_platform` adapter lets consumers
  migrate today with the `NeMoPlatform` they already have. 883 is the follow-up cleanup that removes
  those per-call-site adapters once the factory hands out typed clients directly.
- **AIRCORE-840** — remove the Files Stainless-error-remapping shim. Files was migrated with a
  temporary compat shim; later services skip the shim and migrate consumers directly instead.
- **AIRCORE-893** — migrate the CLI generator to typed clients. The `nemo <svc> *` command group is
  generated by introspecting the Stainless SDK, so it's the last hard Stainless consumer and blocks
  removing the Stainless dependency. The preferred direction is one generic `sdk.nemo_client` seam,
  not per-service shims (a per-service shim was prototyped during Secrets and reverted as the wrong
  shape). See §4.
