Skip to content

fix: keep writeOnly fields out of response documentation (#160) - #180

Merged
clundie-CL merged 1 commit into
camaraproject:mainfrom
cablelabs:160-example-schema-guards
Aug 27, 2026
Merged

fix: keep writeOnly fields out of response documentation (#160)#180
clundie-CL merged 1 commit into
camaraproject:mainfrom
cablelabs:160-example-schema-guards

Conversation

@clundie-CL

@clundie-CL clundie-CL commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Closes #160 — resolves items 1, 4, and the comment-expanded items 5, 6, 8, 9 (item 3 was already fixed on main).

Item 2 (404 declared on the GET /network-access-devices collection endpoint) is deliberately not addressed here: removing a documented response is an API-shape change, and this PR is shape-neutral by design. It is broken out into #188 and tracked there independently.

What was wrong

The rendered documentation (Swagger UI and Redoc) showed the write-only Wi-Fi passphrase securityMode.password in Trust Domain success-response samples, contradicting WpaPersonalDetail.password being writeOnly: true.

Two independent causes:

  1. Response examples reused request-side YAML anchors. TrustDomainResponseBasic and TrustDomainResponseWiFiThread spliced in the same wifi-wpa*-personal-example fragments the create examples use.
  2. Object-level example: blocks on the AccessDetail schemas. This is the wider surface and the reason GET /trust-domains and PATCH /trust-domains/{id} leaked despite having no entry in components/examples at all. An object-level example hangs off the schema, not off a request or a response, and renderers print it verbatim — so it shows in both panes with no read/write filtering.

Why no linter caught it

readOnly/writeOnly are JSON Schema annotations, not assertions. AJV — which Spectral validates examples with — ignores them, and Spectral has no request/response context, so a write-only secret in a 200 example is a valid instance of its schema. Confirmed by control experiment: injecting name: 12345 into the same example is caught immediately by oas3-valid-media-example; the password is not. The centralized CAMARA pipeline is structurally incapable of detecting this class of defect.

redocly lint's no-invalid-media-type-examples is context-aware and did flag cause (1) — but as a warning, and redocly lint exits 0. Nothing flagged cause (2).

Approach

The three example altitudes behave differently, and the distinction is what was missed:

Where Renderer behavior Verdict
property level example: on a leaf property Used when synthesizing a sample by walking properties — and that walk honors readOnly/writeOnly Preferred; source of truth
object/schema level example: beside properties/items/allOf Opaque blob, printed verbatim, no filtering, shown in both panes Removed
media type example:/examples: under content: Verbatim, but anchored to a context so linters can enforce read/write rules Kept, for variants only

Precedence is media type > object level > synthesis from property level, and it is strictly either/or: Redoc's MediaTypeModel is an examples ?? example ?? generate chain and Swagger UI passes a named example to getSampleSchema as an override, so a synthesized sample never appears as an extra dropdown entry alongside curated ones. Where no media-type example exists, the renderer derives the sample from the schema plus its property-level examples — context-aware and always in sync. That is what the hand-maintained _imported_examples anchor blocks were trying to build by hand.

Curated media-type examples are therefore left untouched by this PR. They are the only way to control what a reader sees first, and synthesis is maximal — it populates every optional property — so it is not a substitute for a deliberately minimal "basic" example.

Object-level examples on arrays of scalars are kept — the array is the leaf, there are no properties beneath it to synthesize from and no read/write hazard.

Changes

  • Split the TrustDomains anchor block into labeled request and response fragments; response fragments omit write-only fields.
  • Removed every object-level example: across the modules and backfilled the property-level examples they were masking (MaxDomainDownstreamRatePolicy / MaxDomainUpstreamRatePolicy value and unit). Several of the removed blocks had already drifted: the service-site-example anchor set location: to a PropertyAddress directly, though ServiceSite.location is {geographicPoint, propertyAddress} — the docs were publishing an invalid instance of the spec's own schema; the supportedPolicies example listed 2 of 4 policies; the Policies example contradicted the bandwidth values in the max-bandwidth-policy-example anchor.
  • Dropped the redundant readOnly overlay branches on TrustDomain, TrustDomainDevice, RebootRequest, NetworkAccessDevice. Those fields are already readOnly in ResourceIdentifier/ResourceAudit, and a bare readOnly: true stub with no type wins the allOf merge and blanks the property to null in every generated sample. TrustDomainDevice keeps its ipv4Address/ipv6Address overrides — Commonalities Device does not declare those read-only — restated against DeviceIpv4Address/DeviceIpv6Address so they stop blanking.
  • Removed duplicate example UUIDs that collided as schema identifiers and made redocly's no-invalid-schema-examples abort with resolves to more than one schema — silently skipping validation of Service, ServiceList, ServiceSite, NetworkAccessDevice, NetworkAccessDeviceList.
  • Deleted the unused thread-tlv-example anchor, which carried a mode property that ThreadTlvAccessDetail rejects under additionalProperties: false.
  • Wired the three unreferenced components/examples into operations that had none: RebootRequestResponseInferred / RebootRequestResponseExplicit on POST /reboot-requests, TrustDomainDeviceCreateAssignCredential on device registration. Reconciled the reboot request/response pairs while wiring them: the response now echoes the request's message verbatim, the immediate (inferred) response carries no atTime, and the scheduled atTime is later than createdAt.
  • Added curated two-item list examples for GET /services and GET /network-access-devices. Deleting the object-level list examples would otherwise have cost their multi-item narratives (a service without the optional serviceSite; both connected and disconnected device states), since synthesis emits exactly one array element. These live at the media-type level, so the object-example rule still holds.
  • Gave ResourceIdentifier.id and ResourceAudit.modifiedAt their own property-level examples so synthesized samples stop showing one literal for both id and createdBy, and one timestamp for both createdAt and modifiedAt.
  • Promoted no-invalid-media-type-examples and no-invalid-schema-examples to error in code/redocly.yaml.

No API shape change: no path, operation, schema, field, type, constraint, or scope is added, removed, or altered. The diff is examples, example plumbing, redundant readOnly restatements, and lint severity.

Verification

Gate Result
redocly lint (both specs, rules at error) exit 0
CAMARA release-review Spectral error=0 warning=0 — identical to the pre-change baseline
Context-aware example audit (215 media-type + 143 schema-level examples) 0 findings

Rendered output for GET /trust-domains → 200, checked against openapi-sampler (the library Redoc uses):

BEFORE                                   AFTER
  "id": null,                              "id": "550e8400-...",
  "securityMode": {                        "securityMode": {
    "password": "my-password",   ← leak      "securityModeType": "WPA3-Personal"
    "securityModeType": "..."              }
  },                                       "createdAt": "2023-07-03T14:27:08.312+02:00",
  "createdAt": null,                       "createdBy": "550e8400-...",

The request pane still shows password and correctly omits the audit fields.

Special notes for reviewers

TrustDomainResponseWiFiThread still contains networkKey, deliberately. ThreadStructuredAccessDetail.networkKey is not marked writeOnly, so the spec currently does say the server returns it. Changing the example without changing the schema would have made the example lie in the other direction. That is the substance of #124 and is left to it; the anchor carries a note to split the Thread fragment into request/response halves if those fields become write-only.

Relatedly, WiFiWpaEnterpriseAccessDetail, ThreadStructuredAccessDetail and ThreadTlvAccessDetail also had object-level examples, harmless only because nothing in them is currently writeOnly. Marking the Thread secrets write-only would have turned all three into live leaks. They are removed here, so #124 becomes a schema-only edit.

On the severity promotion. redocly lint reads config only from the working directory, and redocly.yaml lives in code/. Linting from code/API_definitions/ silently falls back to redocly's built-in defaults and does not apply these severities — worth knowing when reproducing locally:

cd code
redocly lint API_definitions/network-access-domains.yaml
redocly lint API_definitions/network-access-devices.yaml

…ct#160)

Success-response documentation showed the writeOnly Wi-Fi passphrase
(securityMode.password) via two independent paths:

- Response examples in components/examples reused request-side YAML anchors.
  Split the anchors into request and response fragments; response fragments
  omit writeOnly fields.
- Object-level `example:` blocks on the AccessDetail schemas embedded the
  password. An object-level example is context-free and is rendered verbatim
  into both the request and the response pane, bypassing writeOnly filtering.
  Removed every object-level example across the modules; renderers now
  synthesize object samples from property-level examples, which are
  readOnly/writeOnly aware. Backfilled the property-level examples that the
  removed blocks were masking.

Also in this pass:

- Drop the redundant readOnly overlay branches on TrustDomain,
  TrustDomainDevice, RebootRequest and NetworkAccessDevice. Those fields are
  already readOnly in ResourceIdentifier/ResourceAudit, and a bare
  `readOnly: true` stub wins the allOf merge and blanks the property to null
  in generated samples. TrustDomainDevice keeps the ipv4Address/ipv6Address
  overrides, which Commonalities Device does not declare readOnly, restated
  against the underlying schemas so they no longer blank.
- Give ResourceIdentifier.id and ResourceAudit.modifiedAt their own
  property-level examples so synthesized samples stop repeating one literal
  for id/createdBy and one timestamp for createdAt/modifiedAt.
- Remove duplicate example UUIDs that collided as schema identifiers and made
  redocly's no-invalid-schema-examples abort without validating five schemas.
- Delete the unused thread-tlv-example anchor, which carried a `mode` property
  that ThreadTlvAccessDetail rejects.
- Wire the three unreferenced components/examples into operations that had no
  example: RebootRequestResponseInferred and RebootRequestResponseExplicit on
  POST /reboot-requests, TrustDomainDeviceCreateAssignCredential on device
  registration. Reconcile the reboot request/response pairs: the response
  echoes the request's message verbatim, the immediate (inferred) response
  carries no atTime, and the scheduled time is later than the audit
  timestamps.
- Add curated two-item list examples for GET /services and
  GET /network-access-devices, preserving the multi-item narratives the
  deleted object-level list examples carried (optional serviceSite omitted on
  one service; connected and disconnected deviceStatus values shown).
- Promote no-invalid-media-type-examples and no-invalid-schema-examples to
  `error` in redocly.yaml. The former is the only check that validates
  examples with request/response context; readOnly/writeOnly are JSON Schema
  annotations rather than assertions, so the centralized Spectral pipeline
  cannot detect this class of defect. The latter validates schema-level
  examples structurally only.

No API shape change.

@caubut-charter caubut-charter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. "Nit list" passed out of band for consideration but not a blocker.

@clundie-CL
clundie-CL merged commit b58be1a into camaraproject:main Aug 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Minor spec hygiene & example correctness (post-split)

2 participants