PoC: change PUT bodies to require all fields#10673
Conversation
…sons in plan notes
| #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] | ||
| pub struct ProjectUpdate { | ||
| pub name: Name, | ||
| pub description: String, |
There was a problem hiding this comment.
We're hard-coding these fields here, but once we convert them all we can just switch IdentityMetadataUpdateParams to look like this and use it flattened like we currently do.
There was a problem hiding this comment.
Added IdentityMetadataUpdateParamsStrict in 26aa0b5. Once all the endpoints are converted, we can rename it to IdentityMetadataUpdateParams.
| opctx: &OpContext, | ||
| project_lookup: &lookup::Project<'_>, | ||
| new_params: &project::ProjectUpdate, | ||
| updates: db::model::ProjectUpdate, |
There was a problem hiding this comment.
A couple of things are ugly about this and project_update_v2025_11_20_00 below: I don't love changing this to take the model — I think I'd rather it continue to take params. I also don't like adding version-aware logic here in the app layer. I think I'd rather have one app layer function that we can just call from the old and new endpoint handlers. Working on it.
Edit: Changed in 890a156
Restores the params-object convention at the handler/app boundary: a single version-agnostic app method takes the prior (lenient) wire type; the latest strict body down-converts to it via From. Drops the per-version app methods and the db-model dependency on the versions crate.
| }, | ||
| "ProjectUpdate": { | ||
| "description": "Updateable properties of a `Project`", | ||
| "description": "Updateable properties of a `Project`\n\nA `PUT` replaces the resource, so every field is required: `name` and `description` must both be present.", |
There was a problem hiding this comment.
In #10675 I undid these changes to the comments. They are pointless.
`api-diff` is a lovely tool (if I may say so) for seeing the effect of OpenAPI schema changes in Omicron on the generated TS client. Until this PR it can only look at GitHub. This is useful for pointing it at a PR number. However, while working on oxidecomputer/omicron#10673 and oxidecomputer/omicron#10675, which cause large diffs in the schema, I found I wanted to be able to see changes before I push them. This PR adds a `--local` flag that lets you run it against local Omicron commits. While I think I wrote the original version of this script by hand, it's gone through a few iterations of full vibe-coding, so while I've skimmed the code, I am really not especially concerned about the details. The way this PR works is it adds a concept of `Source`, which is either GitHub or a local repo, and each has its corresponding methods for listing schema files and reading the schema contents, etc. Nice work from the robot. ```ts /** * A source of schema files. The remote source reads from GitHub; the local * source reads from the git repo in the current directory. Both expose the * same primitives so the diff logic doesn't care where schemas come from. */ type Source = { /** Resolve a ref (PR number, SHA, branch, jj revset, or undefined for the default) to a commit id */ resolveCommit: (ref?: string | number) => Promise<string> /** List schema filenames under openapi/nexus at a commit, or null if the dir is absent */ listSchemaNames: (commit: string) => Promise<string[] | null> /** Read the filename that nexus-latest.json points to at a commit */ readLatestPointer: (commit: string) => Promise<string> /** Read spec content at a commit (the remote source follows gitstub references) */ readSpec: (commit: string, specPath: string) => Promise<string> } ``` This implementation is also `jj`-aware — if the repo in question is a jj repo it will use jj revs instead of git, which is relevant because when you have a colocated jj/git repo, by default (without a rev specified) you probably want to look at `@`, but git's HEAD is at `@-`. If that doesn't mean anything to you, ignore it. <img width="788" height="507" alt="image" src="https://github.com/user-attachments/assets/5c180f0e-5772-46a3-98b1-b8a72ac334c6" />
This prototype came out of the discussion at #10566 (comment). PUT request bodies in the external API do not follow a consistent pattern. Most of them behave like PATCH: they have optional fields that, when left out, leave the value of that field unchanged. We have discussed many times (e.g., #6406) picking a pattern and sticking to it, and generally people have preferred that we switch to what is considered standard PUT semantics, where the body is the same as what you get if you fetch the resource (or at least, it has the full the subset of fields which are mutable) and none of the fields are optional. This has the advantage of avoiding ambiguity around the semantics of leaving a field out — when a field is optional, I always have to check the docs because I never feel confident about what I'm supposed to do with that.
Changes
This PR converts four PUT endpoints, each chosen to to represent a distinct conversion shape so the plan is proven out before converting the remaining ~15:
project_update— identity-only, the most common case (~14 endpoints flatten identity): makenameanddescriptionrequiredvpc_subnet_update— mixed: required name/description plusNullablecustom_routersilo_quotas_update— required quota values, no identity stuffsupport_bundle_update— clearable-onlyNullableuser_comment; also corrects a pre-existing clear-on-omit bugAlso updated the necessary integration tests, and added two new regression tests that exercise the old API version. It occurred to me while doing this that it's probably bad that we don't have any integration tests for old versions of endpoints?
Full list of update endpoints to convert
★ marks the four converted.
name→reqT,description→req (old clients omit via merge)custom_router→Nullabledns_name→reqTendpoint→reqTdestination/targetalready req)primary/transit_ipsalready value-shaped)cpus/memory/storage→reqT(real behavior tightening)user_comment→Nullable(corrects today's clear-on-omit bug)#[serde(default)]on one booldefault; already replace-semanticsBucket legend: B = trivial (drop
default/make required, no identity); C = non-identity, needs per-fieldNullable-vs-required judgment; D = flattensIdentityMetadataUpdateParams(the 14-endpoint identity question is the real crux).Not in scope — already conformant, zero work (15):
instance_update(the model),auth_settings_update, the four*_policy_update(SiloRolePolicy/ProjectRolePolicy/FleetRolePolicy),physical_disk_enable_adoption,sled_set_provision_policy,system_ip_pool_silo_update,system_subnet_pool_silo_update,networking_allow_list_update,networking_inbound_icmp_update,networking_bgp_announce_set_update,system_update_recovery_finish,target_release_update. (Plussystem_update_repository_upload, which has no JSON body.)API version compatibility
Old versions can keep working like they did before — we keep the datastore logic exactly the same (changesets can leave fields out to leave them unchanged). The difference is simply that the new endpoints always include all the fields in the changeset.
🤖 notes (gist)
Initially had these in the diff at
bot-notes/, but obviously that is it not a long term solution. I'm experimenting with this gist approach now. Basically I want a way for them to be semi-permanently attached to the PR and easily viewed with a click (rules out file attachments) without being checked into the repo.2026-06-23-create-body-optionality-audit.md2026-06-23-put-body-optionality-audit.md2026-06-23-put-value-semantics-plan.md2026-06-24-put-value-semantics-session-handoff.md