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.
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 carries | What it means | How we honor it |
|---|---|---|
displayName absent from the JSON | leave the name unchanged | not in model_fields_set β name=None β update_firm treats None as "unchanged" |
addressLine2 absent | leave that column unchanged | not in model_fields_set β kwarg omitted β update_firm sees its _UNSET default β column untouched |
"addressLine2": null | clear just that column | present 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:
- NFC-normalizes the input, so
"e" + combining-acute (U+0301)is stored as the composed"Γ©" (U+00E9)— one canonical byte form per visible name; - rejects bidi-override and zero-width control characters (U+202A–202E, U+2066–2069, U+200B–200D, U+FEFF) — the invisible reordering/spoofing characters that render as a legitimate name but aren't one;
- trims and rejects empty-after-trim, so a whitespace-only
" "is a 422, not a silent blank firm name.
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:
extra="forbid"onApiRequestModelrejects any unknown key with a 422 before the service runs. The request never reaches the database.- A fixed kwarg set:
update_profileforwards only the eight editable fields by name. Even if layer 1 were bypassed, there is no code path that handsemail_domainoronboarding_stagetoupdate_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):
| Scenario | Asserted outcome |
|---|---|
| name-only PATCH | 200; name changed; every sibling column byte-identical; audit changed_fields == ["name"], previous_values["name"] correct |
| omitted email | stored email is the seeded value — neither "None" nor NULL |
explicit addressLine2: null | that field NULL; all siblings byte-identical; name unchanged |
| unknown-field PATCH (Γ7) | 422, zero mutation |
| bidi / zero-width name | 422; emoji + decomposed Devanagari/accented name β 200, stored NFC-composed |
empty {} / {displayName: null} | 422 pramaan.firm.update_empty |
| invalid PIN / email / over-max name | 422 (validators still bind) |
| support-session rename | 200; audit records the support actor (actor_kind == "platform_staff") distinctly |
| non-admin | 403 pramaan.firm_membership.not_admin |
| full PATCH (all fields) | 200, all written (regression) |
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.