Story 1861: a firm admin can rename their firm without re-typing an address they never gave us

Why this exists (the catastrophe it prevents). The write contract for the firm profile, FirmProfileUpdateRequest, declared six address/contact fields as required. So the only way to change one thing — the firm's display name — was to re-send a valid addressLine1, city, state, pinCode, primaryContactEmail and primaryContactPhone alongside it. A firm that never captured an address (every brand-new firm, the empty first-run state) literally could not rename itself: the request 422'd at validation before any service code ran. The "edit one field" product gesture was welded shut by a contract that demanded all six.

The fix makes PATCH /app/firm a true partial update: omit a field β†’ it is left unchanged; send an explicit null on an address/contact field β†’ that one field is cleared. The name alone can travel. No database, migration, route auth, or FirmService.update_firm change — the whole repair lives at the request-validation layer plus the DTO→kwargs mapping. (RFD-FIRM-002, reviewed by a 3-provider adversarial panel.)

πŸ›°οΈ Satellite view: three semantics on one field

What the wire carriesWhat it meansHow we honor it
displayName absent from the JSONleave the name unchangednot in model_fields_set β†’ name=None β†’ update_firm treats None as "unchanged"
addressLine2 absentleave that column unchangednot in model_fields_set β†’ kwarg omitted β†’ update_firm sees its _UNSET default β†’ column untouched
"addressLine2": nullclear just that columnpresent in model_fields_set β†’ kwarg forwarded as None β†’ column set to SQL NULL

The load-bearing distinction is omitted vs. explicit-null, and Pydantic already records it for us: req.model_fields_set is the exact set of fields the caller actually sent. That single set is the whole engine of the partial update.

✈️ Airplane view: the three edits

1. The request DTO loosens, but never relaxes a validator

Every one of the six address/contact fields becomes … | None = Field(default=None, …). The constraints stay attached — a supplied pinCode is still ^[1-9][0-9]{5}$, a supplied email is still an EmailStr, a supplied phone still matches its pattern. Optional means "you may omit it", not "anything goes when you send it". ApiRequestModel(extra="forbid") is untouched and is doing real security work (see Β§3).

2. display_name is cleaned and is non-clearable

A name is the one field that cannot be blanked. A new field_validator runs after the length constraints and:

Emoji and non-Latin scripts stay welcome — "Acme πŸš€ LLP" and a Devanagari name both pass. The rule targets invisible abuse, not unfamiliar writing.

3. The service maps only what was sent — and guards the email coercion

provided = req.model_fields_set
kwargs: dict[str, Any] = {}
if "address_line_1" in provided: kwargs["address_line_1"] = req.address_line_1
...
if "primary_contact_email" in provided:
    # Guard the EmailStr→str coercion: an explicit-null email must be SQL NULL,
    # never the literal string "None".
    kwargs["primary_contact_email"] = (
        str(req.primary_contact_email) if req.primary_contact_email is not None else None
    )
...
firm = await FirmService.update_firm(session, firm_id=firm_id, name=req.display_name, **kwargs)

The old code did primary_contact_email=str(req.primary_contact_email) unconditionally. The moment the field became optional, that line was a corruption bomb: str(None) is the four-character string "None", and a name-only PATCH would have overwritten a real email with the text "None". The guard converts None to SQL NULL and never coerces an absent email at all. This was the adversarial panel's CRITICAL finding A1.

πŸ”¬ Microscopic view: the two-layer field-injection wall

Loosening "required" raises a fair question: could a caller now smuggle plan, gstin, seatLimit or csmOwnerUserId into the same body and write a field they were never meant to touch? No — two independent layers stop it:

  1. extra="forbid" on ApiRequestModel rejects any unknown key with a 422 before the service runs. The request never reaches the database.
  2. A fixed kwarg set: update_profile forwards only the eight editable fields by name. Even if layer 1 were bypassed, there is no code path that hands email_domain or onboarding_stage to update_firm.

A contract test now PATCHes each of plan, seatLimit, emailDomain, gstin, legalName, csmOwnerUserId, onboardingStage and asserts 422 with zero database mutation — the highest-value adversarial test in the set.

The response is trimmed, too (confidentiality)

The settings layout has no admin guard, so a plain member can reach this page. The PATCH used to echo the full profile — plan, seatLimit, seatsUsed, csmOwner. A new FirmProfileUpdateResponse returns only the editable profile fields, built straight from the updated row (no extra read round-trip). A rename — including a support-session rename — never sends billing or CSM data back over the wire (pramaan-docs ADR-099).

What the tests prove (not just exercise)

Every "unchanged" assertion runs against a non-null address/contact pre-seed, so the claim isn't vacuously true against an all-NULL baseline (panel finding A4):

ScenarioAsserted outcome
name-only PATCH200; name changed; every sibling column byte-identical; audit changed_fields == ["name"], previous_values["name"] correct
omitted emailstored email is the seeded value — neither "None" nor NULL
explicit addressLine2: nullthat field NULL; all siblings byte-identical; name unchanged
unknown-field PATCH (Γ—7)422, zero mutation
bidi / zero-width name422; emoji + decomposed Devanagari/accented name β†’ 200, stored NFC-composed
empty {} / {displayName: null}422 pramaan.firm.update_empty
invalid PIN / email / over-max name422 (validators still bind)
support-session rename200; audit records the support actor (actor_kind == "platform_staff") distinctly
non-admin403 pramaan.firm_membership.not_admin
full PATCH (all fields)200, all written (regression)
Deliberately out of scope. FirmService.update_firm still emits a firm_updated audit row with empty changed_fields on a true no-op rename. That is a pre-existing defect tracked as a separate Bug (pramaan-spec #1863) and is not relied upon here — this Story's audit assertions all use a real change. The single-field app UI is the sibling app Story (#1862).

One-sentence recall

model_fields_set turns "which fields did the caller send?" into a true partial PATCH: omit = unchanged, explicit-null = clear, a guarded email coercion kills the "None"-corruption, and extra="forbid" keeps every non-editable field un-writable.