Skip to content

Read-modify-write must not drop production fields the CLI does not model #26

Description

@kshahbw

BLUF

The 10DLC design returns API responses as map[string]any specifically so that production fields absent from the published spec are not silently dropped. Read-modify-write updates reintroduce exactly that loss at write time — and because both brand and campaign PUT are full replacements, the dropped fields get nulled server-side. This needs a deliberate decision before the first update command is written.

Background

Production returns fields the OpenAPI spec does not document — attMessageClass and subId on campaigns, universalEin and referenceId on brands. Go's encoding/json silently discards unknown fields when decoding into a struct, so the foundation PR made responses lossless map[string]any rather than typed structs.

That protects reads. It does nothing for writes.

The problem

Both PUT /brands/{brandId} and PUT /campaigns/{campaignId} state verbatim:

This is a full replacement — omitted optional fields will be set to null.

So update must be read-modify-write: GET current, overlay changed fields, PUT the complete result. The design already specifies a typed response→request mapper for that step, because response and request field names differ in case (subscriberOptIn in responses vs subscriberOptin in requests).

But a typed request struct can only carry fields the CLI models. Any production field it doesn't know about is absent from the PUT body — and the API nulls it.

Concretely: a customer's brand carries referenceId. The CLI's typed brand-update struct has no referenceId. A user runs band tendlc brand update B0IRNU4 --email new@example.com. The mapper builds a request from the fields it knows, the PUT replaces the brand, and referenceId is gone.

This is the failure the lossless decision was meant to prevent, moved from decode-time to write-time — and worse, because it destroys customer data instead of hiding it from output.

Two viable resolutions

Mutate the read map in place. GET into map[string]any, apply changed fields to that map, PUT it back. Lossless end to end. Cost: no typed validation on the update path, and the response→request case mismatch (subscriberOptInsubscriberOptin) has to be handled as an explicit key-rename step on the map.

Typed mapper that round-trips unknown keys. Keep typed requests for validation, but have the mapper carry an Extra map[string]json.RawMessage of everything it didn't recognize and re-emit it on write. Cost: custom marshalling, and a test proving unknown keys survive a full RMW cycle.

I lean toward the second — validation on a 20-plus-field payload with conditional per-brandType requirements is worth keeping — but either is defensible. The unacceptable outcome is deciding this implicitly.

Acceptance

Whichever is chosen, a test must prove it: seed a response containing a field the CLI does not model, run a full read-modify-write cycle, and assert the field is present and unchanged in the outgoing PUT body.

Related

  • Design: docs/specs/2026-08-12-tendlc-direct-registration-center-design.md, "Mutating semantics" and "Design findings carried forward from PR 1's review"
  • Blocks: the update commands in PR 2 (customer profiles) and PR 4 (brands)
  • Found during the final review of PR 1 (foundation), which itself contains no write operations.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions