Skip to content

Make SenderId.kind and registration status real enums - #316

Merged
stephane-segning merged 1 commit into
mainfrom
sender-id-enums
Aug 15, 2026
Merged

Make SenderId.kind and registration status real enums#316
stephane-segning merged 1 commit into
mainfrom
sender-id-enums

Conversation

@stephane-segning

Copy link
Copy Markdown
Contributor

Summary

Turns SenderId.kind and SenderIdRegistration.status into real enums, backed by Postgres CHECK constraints — and replaces the two controls that motivated it with radio groups.

Intent

Both were bare String columns with no @regex, no @db_enforce, and no CHECK. The four statuses existed only as a KNOWN_STATUSES array in the console; kind was a free-text <Input> whose placeholder read "e.g. alphanumeric". Proven before fixing, then rolled back:

BEGIN; UPDATE sender_id_registrations SET status='banana' …
DB ACCEPTED: banana

Provider.kind has had a real CHECK all along — so this was inconsistency, not a policy.

The kind vocabulary is a decision made here

§2.5 declares kind String and never enumerates it, so there was nothing to read off.

The first draft used alphanumeric | numeric. The compiler rejected itvsms-demo-seed and send_test_message have both been writing "shortcode" for an all-digit value since they were written. Nothing enforced it, nothing else knew about it, no fixture used it. An unconstrained column quietly growing a second vocabulary in two corners of the tree is precisely what this ends, so the enum adopts the existing word rather than renaming it in flight.

No long_number: SenderId.value is @length(min: 3, max: 11) and a full E.164 number does not fit, so advertising it would be a false promise.

UI

  • Status is a RadioGroup, not a Select. Four values, and seeing the other three is the decision that drawer exists to make. It also cannot hit Fix Select being unusable inside a drawer — #274 bug, different primitive #315's portal-inside-a-focus-trap bug: RadioGroup renders inline with no portal and no transition. For a small vocabulary in a drawer that makes radio the safer control, not merely the friendlier one.
  • Create's kind is a RadioGroup too, with labels and hints.
  • Both zod schemas are z.enum(...) rather than z.string().min(1).
  • FormFields now pass error — the component supports it and this screen never used it, so a validation failure had nothing to show.
  • New RadioGroup primitive in @vsms/ui (Headless UI); none existed.

asSenderIdKind/asRegistrationStatus narrow at the wire seam, where generated types still say string. The fallback is not defensive padding: rows written before this migration can hold anything the old column accepted, and a console that blanks on one is worse than one that shows it so an operator can correct it. New writes cannot produce an unknown value — the CHECK refuses them.

Verification — live, not by build

CLI matched to the pin first (cratestack 0.7.16 == pin 0.7.16), checked rather than assumed. The regenerated 0001_init differs from the committed one by exactly two CHECK constraints and nothing else.

Against a scratch database:

applying 0001_init / 0002_bootstrap / 0003_idempotency_table … up to date
INSERT (kind='alphanumeric')              → INSERT 0 1
INSERT (kind='banana')                    → violates sender_ids_kind_enum_check
INSERT (status='banana')                  → violates sender_id_registrations_status_enum_check

Against the running console:

radio renders  [pending, submitted, approved, rejected]
picking "rejected" saved → row reads `rejected`, version 2
the same UPDATE … 'banana' that succeeded an hour ago now errors

fmt, clippy -D warnings, R1, R2 parity, bootstrap-sql-check, R6, 129 frontend tests, 23/23 routes, biome — all clean. The SDK's vendored schema was re-vendored in the same change, the step AGENTS.md records as having made #185 red; its own guard caught the omission.

Risk Assessment

This is a hard cutover on live data. The CHECK is added unconditionally, so any pre-existing row holding a value outside the vocabulary makes the migration fail on a non-empty database. There is no such row today (kind is alphanumeric everywhere; status is pending/approved), and per AGENTS.md there is still no production deployment — but a deployment that had accumulated a stray value would need a backfill before this applies.

shortcode has never actually been written to any database — both call sites only produce it for an all-digit sender value, and every seeded sender is alphanumeric. So the value is real in code and untested in practice.

The narrowing fallback silently coerces an unknown legacy value to alphanumeric/pending rather than surfacing it. That is the right trade for a console but does mean a bad legacy row displays as something it is not, until saved.

AI Usage Declaration

  • A human directed this change and is accountable for it.
  • Claims in this PR were verified against a running system.

Reviewer Focus

  1. The kind vocabulary. Two values is my call, taken from what the code already writes. If sender kinds should distinguish more than letters-vs-digits, this is the moment.
  2. Radio vs select for four options in a drawer. I went radio for both reasons above; if the review drawer feels crowded, status could go back to Select now that Fix Select being unusable inside a drawer — #274 bug, different primitive #315 makes that safe.

Checklist

  • docs/roadmap.md checked — no milestone/gate/dependency change.
  • Migrations regenerated per the documented workflow, CLI matched to the pin, verified against a real Postgres.
  • New R1 exceptions — none.

Both were bare `String` columns with no `@regex`, no `@db_enforce`, and no
CHECK. The four statuses existed only as a `KNOWN_STATUSES` array in the
console, and `kind` was a free-text `<Input>` whose placeholder read
"e.g. alphanumeric". Proven before fixing, then rolled back:

    BEGIN; UPDATE sender_id_registrations SET status='banana' …
    DB ACCEPTED: banana

`Provider.kind` has had a real CHECK all along, so this was inconsistency
rather than a policy.

**The `kind` vocabulary is a decision made here** — §2.5 declares
`kind String` and never enumerates it. The first draft used
`alphanumeric | numeric`; the compiler rejected it, because
`vsms-demo-seed` and `send_test_message` have both been writing
`"shortcode"` for an all-digit value since they were written. Nothing
enforced that, nothing else knew about it, and no fixture used it — an
unconstrained column quietly growing a second vocabulary in two corners of
the tree is precisely what this change ends. The enum adopts the existing
word rather than renaming it in flight.

No `long_number`: `SenderId.value` is `@length(min: 3, max: 11)` and a
full E.164 number does not fit, so advertising it would be a false
promise.

Migrations regenerated per the documented workflow, CLI matched to the pin
first (`cratestack 0.7.16` == pin `0.7.16`, checked rather than assumed).
The diff against the committed `0001_init` is exactly two CHECK
constraints and nothing else.

UI, answering the report that prompted this — a status that could not be
changed, and a free-text field where an enum belonged:

  - Status is a `RadioGroup`, not a `Select`. Four values, and seeing the
    other three *is* the decision that drawer exists to make. It also
    cannot hit #315's portal-inside-a-focus-trap bug at all: `RadioGroup`
    renders inline with no portal and no transition, which makes radio the
    safer control here and not merely the friendlier one.
  - Create's `kind` is a `RadioGroup` too, with labels and hints, instead
    of a text box.
  - Both zod schemas are `z.enum(...)` rather than `z.string().min(1)`.
  - `FormField`s now pass `error` — that component supports it and this
    screen never used it, so a validation failure had nothing to show.

New `RadioGroup` primitive in `@vsms/ui` (Headless UI), since none
existed.

`asSenderIdKind`/`asRegistrationStatus` narrow at the wire seam, where the
generated types still say `string`. The fallback is not defensive padding:
rows written before this migration can hold anything the old column
accepted, and a console that blanks on one is worse than one that shows it
so an operator can correct it. New writes cannot produce an unknown value.

Verified end to end on the live stack, not by build:

  - migrations applied to a scratch database; `kind='banana'` and
    `status='banana'` both refused by name, a valid row inserted fine
  - the radio group renders all four options, picking `rejected` saved,
    and the row reads `rejected` at version 2
  - the same `UPDATE … 'banana'` that succeeded before now errors on
    `sender_id_registrations_status_enum_check`

fmt, clippy `-D warnings`, R1, R2 parity, bootstrap-sql-check, R6, 129
frontend tests, 23/23 routes, biome — all clean. The SDK's vendored schema
was re-vendored in the same change, the step AGENTS.md records as having
made #185 red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 15, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: de92e3c

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@lightbridge-assistant lightbridge-assistant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🅵 Fast automated pass — SAST + a quick, diff-scoped look (no repo-wide retrieval). For a deeper, repo-aware review, mention @lightbridge-assistant on this PR.

Reviewed sdks/rust/vsms-sdk-rust/schema.cstack (this pass was the only unopened file): it is an exact byte-for-byte mirror of the already-reviewed schemas/vsms.cstack for these changes — the two new enums have identical members/comments and both SenderId.kind / SenderIdRegistration.status are retyped identically with matching policies, so nothing drifts between the two schema copies. Combined with the earlier full review (migration CHECK constraints match both Rust and TS enums and are balanced in down.sql; all backend write paths use the enum constants; all frontend z.enum schemas/defaults/resets migrated; the wire-narrowing fallbacks protect the strict enums against pre-migration rows; SAST clean), the change is correct and safe. Only findings remain the two P2 accessibility label-association regressions in create-sender-dialog.tsx and registration-review-fields.tsx.


🤖 AI-generated review — treat it as untrusted, verify before acting; a human owns the final decision (AI governance).

was an unconstrained `String` with a placeholder reading
"e.g. alphanumeric" — so the honest answer to "can I type
banana here" was yes, all the way to the database. It is a
real enum now, and there are two values, so showing both

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 quality

Dangling htmlFor on Kind field

The old control was <Input id="new-sender-kind">, which the <Label htmlFor> referenced. The new RadioGroup renders with no id (only an aria-label="Sender ID kind"), so htmlFor="new-sender-kind" now dangles to a non-existent element. FormField's own doc makes htmlFor required because a dangling label silently breaks click-to-focus and the label→control screen-reader association — the group is announced by the separate aria-label rather than the visible "Kind" label (two names, one visual). Consider forwarding an id prop to the RadioGroup (or wiring aria-labelledby to the label). Minor, non-blocking.

Evidence: <FormField label="Kind" htmlFor="new-sender-kind"> (line 72) wraps <RadioGroup aria-label="Sender ID kind" .../> (line 84) which carries no id. Old code used <Input id="new-sender-kind">. FormField renders <Label htmlFor={htmlFor}>.

Was this useful? React 👍/👎 to give us feedback

{/* A radio group, not a select: four values, and seeing the other
three is the decision this drawer exists to make. It also cannot
hit #315's portal-inside-a-focus-trap bug, since `RadioGroup`
renders inline with no portal and no transition. */}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 quality

Dangling htmlFor on Status field

Same regression as in create-sender-dialog: the replaced <SelectTrigger id="registration-status"> (which the label's htmlFor referenced) is now a <RadioGroup aria-label="Registration status"> with no id, so htmlFor="registration-status" dangles. The visible "Status" label is no longer programmatically associated with the control. Wire an id/aria-labelledby through to the RadioGroup.

Evidence: <FormField label="Status" htmlFor="registration-status"> (line 24) wraps <RadioGroup aria-label="Registration status" .../> which has no id.

Was this useful? React 👍/👎 to give us feedback

stephane-segning added a commit that referenced this pull request Aug 15, 2026
Review finding on #316, confirmed and broader than reported.

`FormField` renders `<Label htmlFor={htmlFor}>`, and `RadioGroup`/
`ChipSelect` accept no `id`. So every field converted from an `<Input>` to
a group left the label pointing at an element that does not exist:

    sender-kind            no element carries this id
    new-sender-kind        no element carries this id
    registration-status    no element carries this id
    client-scopes          no element carries this id

The review flagged two (#316's); the other two are this branch's own. All
four were dangling — before the conversion each had a real `<Input id=…>`
to associate with.

Adding an `id` to the group would not fix it. HTML's `for` only associates
with *labelable* form controls, and a `role="radiogroup"`/`role="group"`
wrapper is not one — the reference would still be invalid, just no longer
obviously broken.

So `FormField` gains `control="group"`. In that mode it emits no `for` at
all; the label carries `groupLabelId(htmlFor)` and the grouped control
points back with `aria-labelledby`. Both sides derive the id from the same
exported function, so they cannot drift — which matters, because a second
hand-written string is how the original `htmlFor` came to reference
nothing in the first place.

The four call sites move from `aria-label` (which worked, but duplicated
the visible label as a second string) to `aria-labelledby` pointing at the
visible label itself.

Verified statically, since this is a DOM-identity property rather than a
behavioural one: no element carried any of the four ids before, and each
grouped field now pairs `control="group"` with a matching
`groupLabelId(...)`, checked per id rather than in aggregate.

typecheck (admin + @vsms/ui), 129 tests, 23/23 routes, biome, R6 — clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stephane-segning

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in f1f03e4 (on #317, which stacks on this). Verified against the tree before acting, per this repo's convention on bot findings.

The finding is real, correctly rated P2, and broader than reported. FormField renders <Label htmlFor={htmlFor}>, and RadioGroup/ChipSelect accept no id — so every field converted from an <Input> to a group left the label pointing at a non-existent element. Checked each id individually rather than in aggregate:

sender-kind            no element carries this id
new-sender-kind        no element carries this id
registration-status    no element carries this id
client-scopes          no element carries this id

You flagged the two in this PR; the other two are #317's own. All four were dangling, and each had a real <Input id=…> before the conversion — so it is a regression, not a pre-existing gap.

The obvious fix would have been wrong. Adding an id to the group makes the reference resolve, but HTML's for only associates with labelable form controls, and a role="radiogroup"/role="group" wrapper is not one. That would have silenced the symptom while leaving the association invalid — and harder to spot next time.

FormField gains control="group" instead: no for at all, the label carries groupLabelId(htmlFor), and the control points back with aria-labelledby. Both sides derive the id from one exported function, since a second hand-written string is how the original htmlFor came to reference nothing.

The call sites also move off aria-label, which worked but duplicated the visible label as a second string that could drift from it.

On the review itself, since this feeds a product: accurate, correctly scoped, and it caught something neither tsc, biome, nor a passing build can see — a DOM-identity property that only exists across two files. The severity is right; nothing is broken for a mouse user, and it is a real barrier for a screen reader. My one note is scope rather than correctness: the same defect existed twice more in the stacked branch, and a diff-scoped pass had no way to see that. Not a miss — worth knowing the finding generalises.

@stephane-segning
stephane-segning merged commit c6b3f65 into main Aug 15, 2026
9 checks passed
@stephane-segning
stephane-segning deleted the sender-id-enums branch August 15, 2026 15:51
stephane-segning added a commit that referenced this pull request Aug 15, 2026
Review finding on #316, confirmed and broader than reported.

`FormField` renders `<Label htmlFor={htmlFor}>`, and `RadioGroup`/
`ChipSelect` accept no `id`. So every field converted from an `<Input>` to
a group left the label pointing at an element that does not exist:

    sender-kind            no element carries this id
    new-sender-kind        no element carries this id
    registration-status    no element carries this id
    client-scopes          no element carries this id

The review flagged two (#316's); the other two are this branch's own. All
four were dangling — before the conversion each had a real `<Input id=…>`
to associate with.

Adding an `id` to the group would not fix it. HTML's `for` only associates
with *labelable* form controls, and a `role="radiogroup"`/`role="group"`
wrapper is not one — the reference would still be invalid, just no longer
obviously broken.

So `FormField` gains `control="group"`. In that mode it emits no `for` at
all; the label carries `groupLabelId(htmlFor)` and the grouped control
points back with `aria-labelledby`. Both sides derive the id from the same
exported function, so they cannot drift — which matters, because a second
hand-written string is how the original `htmlFor` came to reference
nothing in the first place.

The four call sites move from `aria-label` (which worked, but duplicated
the visible label as a second string) to `aria-labelledby` pointing at the
visible label itself.

Verified statically, since this is a DOM-identity property rather than a
behavioural one: no element carried any of the four ids before, and each
grouped field now pairs `control="group"` with a matching
`groupLabelId(...)`, checked per id rather than in aggregate.

typecheck (admin + @vsms/ui), 129 tests, 23/23 routes, biome, R6 — clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stephane-segning added a commit that referenced this pull request Aug 15, 2026
* Finish the enum work: sender-id edit form, and scopes as chips

Two gaps reported against the enum migration, both real.

**1. The sender-id *edit* form still had `kind` as a text input.** The
migration converted the create dialog and missed this one — so a sender
created from a closed vocabulary could still be edited back to free text.
That is the more dangerous half: create runs once, edit is the path an
operator uses repeatedly. Now the same `RadioGroup`, with the same labels
and hints.

**2. Provisioning a client asked for scopes as space-separated text.** An
operator had to already know both that scopes are space-delimited *and*
what the valid strings are, with a typo silently producing a client that
is denied at Layer 2 with no hint why. Now a `ChipSelect` over the real
vocabulary, each chip carrying what the scope actually permits.

The scope list was derived from what the server enforces, not from the
design doc or memory:

    grep -rhoE 'require_permission\([^,]+, *"[a-z:]+"' backends/crates/
    grep -rhoE '"[a-z]+:[a-z]+"' backends/crates/sms-api/src/router.rs

Two near-misses were checked and deliberately excluded, because offering a
scope nothing enforces is worse than offering none — it implies a control
that does not exist:

  - `message:send` appears only as test fixture data in `rbac.rs`'s own
    unit tests. AGENTS.md records the seeded role permissions being
    renamed `message:send` -> `sms:send` precisely because the literals had
    drifted from what `require_permission` checks.
  - `provider:write` appears only inside a doc comment in `router.rs`
    explaining a past bug — the constant once checked that literal, which
    matched nothing, permanently denying a legitimate operator token.

`scopes.ts` records that derivation, and states plainly that nothing
mechanically ties the list to the Rust literals: a new
`require_permission("thing:do")` will not appear there on its own. That
wants an `xtask` parity check of the kind that already guards the state
machines. Not built here — flagged, so the next person adding a scope
knows there are two places.

New `ChipSelect` primitive in `@vsms/ui`. Like `RadioGroup`, it renders
inline with no portal and no transition, so it cannot hit the focus-trap
bug that made `Select` unusable inside a drawer (#315). Headless UI 2.2.10
exports no `CheckboxGroup` — checked, not assumed, `tsc` rejected it — so
grouping is a plain `role="group"` wrapper rather than a new dependency.

`serializeScopes` emits in vocabulary order rather than click order, so
two identical grants do not diff against each other.

Verified live: the sender edit drawer now renders
`["Alphanumeric — A brand name, 3–11 characters", "Short code — All
digits"]` with `kindIsTextInput: false`.

NOT verified live: the scope chips rendering. The old `#client-scopes`
input is confirmed gone from the apps panel, and typecheck/build/tests
pass, but I could not get the provision drawer open in the browser
harness to see the chips themselves. Stated rather than implied.

typecheck (admin + @vsms/ui), 129 tests, 23/23 routes, biome, R6 — clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Label the radio and chip groups properly, not with a dangling `for`

Review finding on #316, confirmed and broader than reported.

`FormField` renders `<Label htmlFor={htmlFor}>`, and `RadioGroup`/
`ChipSelect` accept no `id`. So every field converted from an `<Input>` to
a group left the label pointing at an element that does not exist:

    sender-kind            no element carries this id
    new-sender-kind        no element carries this id
    registration-status    no element carries this id
    client-scopes          no element carries this id

The review flagged two (#316's); the other two are this branch's own. All
four were dangling — before the conversion each had a real `<Input id=…>`
to associate with.

Adding an `id` to the group would not fix it. HTML's `for` only associates
with *labelable* form controls, and a `role="radiogroup"`/`role="group"`
wrapper is not one — the reference would still be invalid, just no longer
obviously broken.

So `FormField` gains `control="group"`. In that mode it emits no `for` at
all; the label carries `groupLabelId(htmlFor)` and the grouped control
points back with `aria-labelledby`. Both sides derive the id from the same
exported function, so they cannot drift — which matters, because a second
hand-written string is how the original `htmlFor` came to reference
nothing in the first place.

The four call sites move from `aria-label` (which worked, but duplicated
the visible label as a second string) to `aria-labelledby` pointing at the
visible label itself.

Verified statically, since this is a DOM-identity property rather than a
behavioural one: no element carried any of the four ids before, and each
grouped field now pairs `control="group"` with a matching
`groupLabelId(...)`, checked per id rather than in aggregate.

typecheck (admin + @vsms/ui), 129 tests, 23/23 routes, biome, R6 — clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use a <fieldset> for the chip group, not <div role="group">

Biome's `a11y/useSemanticElements` is right: the native element beats the
ARIA role, and a checkbox group is exactly what `<fieldset>` is for.

Worth recording how it was found, because the mistake is reusable. A local
`pnpm exec biome check frontends` passed. CI runs `pnpm biome ci .` — a
different command over a different scope — and failed. That is the third
time this pattern has cost a round trip: `cargo clippy -p xtask` vs
`--workspace` for the R6 guard, and `npm publish --dry-run` vs `npm pack`
for the SDK. Run CI's exact command, not a plausible neighbour of it.

`min-w-0` is load-bearing, not tidying: a `<fieldset>` carries a UA
`min-width: min-content` that a `<div>` does not, which would otherwise
stop the chips wrapping inside a narrow drawer.

Verified with CI's own command this time: `pnpm biome ci .` — 394 files,
no fixes applied, exit 0. typecheck (admin + @vsms/ui), 129 tests, 23/23
routes all still clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant