Most teams model the same resource three times: a User for responses, a CreateUser for requests, and a UpdateUser for patches. The copies drift within a sprint. The response forgets a field the request requires; the create DTO starts accepting id; someone adds password_hash to the response "temporarily." The duplication is the bug. OpenAPI already lets one schema describe both directions with two annotations: readOnly and writeOnly.

The two annotations are directional

AnnotationSent in request?Returned in response?Typical fields
readOnly: trueIgnored or rejectedYesid, created_at, etag, computed totals
writeOnly: trueYesNeverpassword, current_password, tokens, card secrets
neitherYesYesNormal mutable fields like display_name

readOnly means the client may read but never writes the value. writeOnly is the mirror: the client may send it, but it is never serialized back. They are not hints; codegen and validators treat them as part of the contract.

One User schema, both directions

User:
  type: object
  required: [id, email, created_at]
  properties:
    id:
      type: string
      format: uuid
      readOnly: true
    email:
      type: string
      format: email
    display_name:
      type: string
    created_at:
      type: string
      format: date-time
      readOnly: true
    updated_at:
      type: string
      format: date-time
      readOnly: true
    password:
      type: string
      format: password
      minLength: 12
      writeOnly: true
      description: Required on create. Ignored on profile updates; use the password endpoint to change it.

The same User schema now describes what GET /users/{id} returns and what POST /users accepts. A generator that understands the annotations splits it for you. The response type includes id and timestamps and excludes password; the request type includes password and excludes the server-managed fields:

// Response type (what GET returns)
export interface User {
  id: string;
  email: string;
  display_name?: string;
  created_at: string;
  updated_at?: string;
}

// Request type (what POST accepts) — password present, id/timestamps absent
export type UserCreate = Omit<
  User, "id" | "created_at" | "updated_at"> & { password: string };

Not every generator emits the split automatically; older ones produce one interface and document the annotations in comments. If yours does not split, define thin request/response schemas with allOf and $ref so you still define each field once (see below).

writeOnly is a security control

writeOnly is the correct home for every secret a client sends once: passwords, password-confirmation fields, the current password on a change endpoint, API key seeds, and raw card tokens. Marking them writeOnly does three things:

  1. They never appear in a response schema, so generated response types cannot leak them.
  2. Documentation renderers hide them from response examples.
  3. A spec-driven scanner or response validator can flag an endpoint that actually returns a field declared writeOnly.

Annotations do not replace server-side discipline, you still must not log or persist secrets in reversible form, but they make the contract express the intent and let tooling catch regressions. Pair format: password with writeOnly: true; the format affects rendering and validation hints, while writeOnly controls direction.

The required-field rule

A readOnly property that is required is required only in responses. A writeOnly property that is required is required only in requests. Generators and validators that honor the annotations apply this automatically, which is how id can be required on the way out without being sent on the way in.

This matters for create versus update. The password is required to register but absent on a profile edit. Express the two request shapes by composing the shared schema rather than copying fields:

UserCreate:
  type: object
  required: [email, password]
  allOf:
    - $ref: '#/components/schemas/User'

UserUpdate:
  type: object
  description: Profile update. Send only fields to change; password is not accepted here.
  allOf:
    - $ref: '#/components/schemas/User'

UserCreate adds password and email to the required set; UserUpdate leaves them optional and points password changes to a dedicated endpoint. Both reuse the single User definition, so adding a field to the resource updates every direction in one place.

PATCH and writeOnly fields

Partial updates interact badly with secrets. If PATCH /users/{id} accepts the same schema as create, clients cannot tell whether omitting password means "leave it unchanged" or "clear it," and a generated form may demand a password on every edit. Keep secret changes on a dedicated route with an explicit shape:

paths:
  /users/{id}/password:
    put:
      summary: Change the current user's password
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, new_password]
              additionalProperties: false
              properties:
                current_password: { type: string, writeOnly: true }
                new_password: { type: string, format: password, minLength: 12, writeOnly: true }
      responses:
        '204': { description: Password changed }
        '401': { description: current_password is incorrect }

This removes the ambiguity entirely: the profile PATCH never carries a password, and the password endpoint always carries both the old and the new.

What scanners and AI callers get wrong

When a spec is reconstructed from code, a naive parser reads every field on a model and puts it in both the request and response, which is how internal fields like password_hash, role, and internal flags leak into the create contract, and how server-generated id ends up marked as a required input. A deterministic scanner should trace which fields the handler actually reads versus serializes, mark the rest readOnly or writeOnly, and report a gap when the framework cannot prove the direction (for example a model reused blindly for both binding and response).

An AI agent or generated client that respects the annotations will not send id or created_at on a POST and will not expect password in a GET response. That removes a whole class of pointless fields from agent-generated requests.

Checklist

  1. Mark every server-generated field readOnly and every secret-input field writeOnly; leave genuinely mutable fields unannotated.
  2. Use one core schema per resource and compose create/update variants with allOf and $ref instead of copying fields.
  3. Remember required readOnly applies to responses and required writeOnly applies to requests.
  4. Pair secret fields with format: password and move password changes to a dedicated endpoint so PATCH never carries them.
  5. Never return a writeOnly field; use response validation or a scanner rule to enforce it.
  6. Generate the client and confirm request types exclude id/timestamps and response types exclude secrets.
  7. When scanning code, derive direction from what the handler reads and serializes, and mark unknown direction as a gap.

Get these right and a single schema stays the source of truth for every direction, secrets stay out of responses, and generated create and update types are correct without three copies to maintain.

You can annotate one schema for both directions, generate split request and response types, and verify secrets never appear in a mock response in one local-first workspace, right in your browser. For the partial-update semantics that make the dedicated password endpoint necessary, see PUT vs JSON Merge Patch vs JSON Patch.