Files
simplex-chat/plans/2026-06-25-name-resolution.md
T
shGitHubEvgeny PoberezkinEvgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
10a814694c core, ui: support SimpleX names (#7045)
* deps: bump simplexmq for ConnectTarget

* chat: migration adds simplex_name to contacts, groups, connections

Nullable TEXT column on all three tables, with partial indexes on
contacts(user_id, simplex_name) and groups(user_id, simplex_name)
for the upcoming connectPlanName lookup. connections.simplex_name
is the transient carrier from APIConnect -> XInfo handler, where
the value is copied to contacts.simplex_name at delayed create.
No reads or writes yet - column threading lands in subsequent commits.

* tests: provide namesConfig = Nothing in smpServerCfg

Follow-up to the simplexmq pin bump (ee0a45e9). The new
namesConfig :: Maybe NamesConfig field on ServerConfig (introduced
in simplexmq's namespace branch) needs to appear in the test
fixture's record literal, otherwise the test suite fails to compile
under -Werror. Disabled by default (Nothing).

* chat: thread simplexName through Contact/GroupInfo/Connection records

Adds `simplexName :: Maybe SimplexNameInfo` to the three records and
extends every SELECT path that reconstructs them to read the new
column. Decoded via eitherToMaybe . strDecode . encodeUtf8 (the
codebase's established pattern for Maybe Text -> typed-decode fields),
extracted as decodeSimplexName helper since the chain appears in
toContact / toContact' / toGroupInfo / toConnection. INSERT paths
still write Nothing - the write-side wiring lands in the next commit
(Task 7).

* deps: bump simplexmq for SimplexNameInfo FromField/ToField

* chat: cross-reference groupInfoQueryFields and getGroupAndMember_

Two helpers redundantly maintain the same g.* column list. A future
g.* addition must be applied to both sites; the cross-reference
comments flag this for maintainers. A proper refactor (reusing
groupInfoQueryFields from Connections.hs's inline SELECT) is out of
scope for this branch.

* chat: persist simplexName on prepare and connect-via-plan write paths

createPreparedContact/createPreparedGroup gain a Maybe SimplexNameInfo
parameter that they write to contacts.simplex_name / groups.simplex_name
directly. createConnection_ writes to connections.simplex_name as a
transient carrier for the connect-via-plan path. The XInfo handler
in Library/Subscriber.hs reads the connection's simplexName and passes
it to createDirectContact so the final contact row captures the name.

All current callers pass Nothing; the actual flow lights up when
APIConnectPlan accepts ConnectTarget and connectPlanName threads the
name through (later commits in this branch).

Uses the upstream ToField SimplexNameInfo (simplexmq 0b334b66) for
writes; reads continue to go via the soft-degradation helper.

* chat: APIConnectPlan accepts ConnectTarget; connectPlanName looks up by name

APIConnectPlan/Connect flip from Maybe AConnectionLink to Maybe ConnectTarget.
connectPlan dispatches CTLink -> connectPlanLink (the prior body, renamed)
and CTName -> connectPlanName (new) which looks the name up against
contacts.simplex_name and groups.simplex_name via the new
getContactBySimplexName / getGroupInfoBySimplexName store helpers.

The hit path returns the contact's / group's stored conn link from
preparedContact / preparedGroup; missing prepared state or unknown
names return CEInvalidConnReq. RSLV on-chain resolution is out of
scope for this branch -- known-name lookup is enough for conversation
display, search, and external-link share.

connLinkP_ parser is unchanged: APIConnect's preparedLink_ stays
ACreatedConnLink-shaped, and the Connect / APIConnectPlan parsers
already use inline strP for ConnectTarget without going through the
helper.

Directory.Service call sites updated to wrap their AConnectionLink in
CTLink when invoking APIConnectPlan.

* chat: surface simplexName in conversation view + JSON output

viewConnectionPlan now shows the simplex name beneath known
contacts/groups (ILPKnown, CAPKnown, CAPContactViaAddress, GLPKnown
active/prepared/deleted). The TH-derived Contact / GroupInfo JSON
instances automatically expose `simplexName` (omitted when Nothing
per defaultJSON's omitNothingFields), which unblocks client-side
display and search.

No JSON test added: there is no Contact-level JSON test module in
this codebase; coverage is already provided by defaultJSON's
omitNothingFields = True behaviour.

Server-side substring search has no existing pattern in this
codebase; client renderers index simplexName themselves once it
appears in the JSON shape.

External-link share (preferring simplex:/name… form over the raw
link when the contact has a simplexName) lands in the next commit.

* chat: share/copy output prefers simplexName when present

When a contact or group has a simplex_name stored, the share-link
render path emits the canonical simplex:/name... URI (via strEncode)
instead of the underlying connection link. Falls back to the existing
link rendering when simplexName is Nothing.

Final commit of the ConnectTarget plumbing chain: end-to-end users
can now (a) connect via @alice.simplex / #group.simplex with the
agent layer carrying the name, (b) see the simplex name on the
contact/group records and in viewConnectionPlan, (c) share the
contact using the namespace-canonical form rather than the raw URI.

* deps: bump simplexmq for boundedNonSpace + drop unused FromField

* chat: simplex_name partial indexes are UNIQUE

A simplex name is a stable, per-user identity (one name → one contact
or group). Without a unique constraint, a later writer that populates
the column twice for the same name would silently produce two matching
rows, and getContactBySimplexName/getGroupInfoBySimplexName would
return whichever the planner picks first.

Promote the partial indexes added in M20260603 to UNIQUE before any
caller wires the writes. Predicate (WHERE simplex_name IS NOT NULL)
already scopes the constraint to rows that opted in.

* chat: regen Postgres schema dump for UNIQUE simplex_name indexes

Follow-up to f71c579c. The SchemaDump test runs against SQLite only;
the parallel PostgresSchemaDump suite gates on -fclient_postgres and
a running localhost PG instance, which this environment doesn't have.
Updated the Postgres schema dump by hand to mirror the migration
change (two lines: CREATE INDEX → CREATE UNIQUE INDEX).

* chat: CESimplexNameNotFound for name lookup misses

connectPlanName now distinguishes "name not found" from "connection
link is invalid". CEInvalidConnReq's message ("Connection link is
invalid, possibly it was created in a previous version") was
misleading when a user typed @alice.simplex against a database that
simply has no contact by that name.

The two "missing prepared link" cases stay on CEInvalidConnReq —
the lookup found a row but the stored link is unusable, which is
closer to the existing semantics. The two truly-missing cases
(no contact found / no group found) move to CESimplexNameNotFound,
which also surfaces the name back to the client for a precise UX.

* chat: exclude soft-deleted contacts from idx_contacts_simplex_name

The lookup `getContactBySimplexName` (Store/Direct.hs:781) filters
`AND deleted = 0`, but the index predicate `WHERE simplex_name IS NOT
NULL` covered tombstoned rows too. Forward-compat trap: once writers
land a non-Nothing simplex_name, soft-deleting a contact would block
re-claiming its name (UNIQUE conflict) even though the lookup reports
the slot as free.

Tighten the partial-index predicate to also require deleted = 0 so the
constraint scope matches the live-lookup scope. Groups have no soft-
delete column, so their index stays as-is.

* chat: connectPlanName dispatches on nameType first

Previously the function always probed contacts.simplex_name first and
fell through to groups for NTPublicGroup misses. But the discriminator
(`@`/`#`) is embedded in the stored bytes via strEncode, so an
`#group.simplex` lookup can never match a contact row. Reorder to
case on nameType up front, saving one DB query and one withFastStore
transaction acquire on the group path.

* chat: CESimplexNameUnprepared for found-but-no-link cases + member-removed filter

connectPlanName previously threw CEInvalidConnReq when a name lookup
hit a contact / group row whose preparedContact / preparedGroup was
NULL. The error message ("Connection link is invalid, possibly it was
created in a previous version") was wrong: the name resolved fine,
the device just has no link material to reconnect via (typical for a
contact created via the XInfo handler rather than the prepare path).
Introduce CESimplexNameUnprepared SimplexNameInfo for this case.

Also mirror the link-based path's gPlan (Commands.hs:4133) for groups
whose membership state is GSMemRemoved — return CESimplexNameNotFound
rather than GLPKnown for a removed-member group, since GLPKnown for
removed members would be inconsistent with how /_connect plan over a
short link handles the same situation.

* chat: fix stale CRITICAL comment in saveConnInfo

Comment claimed SEDBException is re-thrown as CRITICAL but only
SEDBBusyError is (via the `critical` helper at Subscriber.hs:136
and the showCritical branch at :1695). Updated to describe the actual
behaviour.

* chat: fix misleading decodeSimplexName docstring

The comment described "@alice.simplex" as the column's surface form,
but ToField SimplexNameInfo writes the canonical strEncode output
("simplex:/name@alice.simplex"). Aligns the docstring with what the
column actually holds.

* chat: drop redundant T.unpack in CESimplexName* error rendering

plain has a Text instance (verified by sibling simplexNameLine at
View.hs:2176 which uses it directly). The T.unpack in the new error
renderings was inconsistent with the same-feature helper. Cosmetic
cleanup.

* deps: bump simplexmq for resolveSimplexName

* chat: simplexName field on Profile, GroupProfile, LocalProfile

Adds a Maybe SimplexNameInfo field to the wire-level Profile and
GroupProfile (and their DB sibling LocalProfile). JSON instances are
TH-derived with omitNothingFields = True, so the new optional field is
auto-handled and old peers / old JSON without the key decode as Nothing.

Existing record-construction sites are set to simplexName = Nothing as
a placeholder. Outgoing dissemination (userProfileDirect /
userProfileInGroup) and incoming persistence wire-up land in follow-up
commits. redactedMemberProfile passes the field through, matching how
peerType is preserved.

* chat: load LocalProfile.simplexName from simplex_name column

Populate the embedded LocalProfile.simplexName field for the user's own
profile and for peer Contact / GroupInfo from the existing simplex_name
columns on contacts and groups. Previously every DB read set this field
to Nothing (Task 1 placeholder), so downstream consumers that work off
LocalProfile / GroupProfile (e.g., userProfileDirect / userProfileInGroup
that build outgoing XInfo / XGrpInfo via fromLocalProfile) saw Nothing
unconditionally.

Scope is limited to the rows where the simplex_name column actually
exists: contacts (per-user) and groups (per-user). Sites that only read
contact_profiles / group_profiles (toContactRequest, toContactProfile,
toGroupProfile, rowToLocalProfile) remain Nothing; Task 3 adds the
profile-table columns and wires them up.

* chat: test outgoing Profile carries simplexName from User profile

userProfileDirect, userProfileInGroup' and redactedMemberProfile already
pass simplexName through via fromLocalProfile (Task 1) once the embedded
LocalProfile field is populated (previous commit). Lock that behavior in
with focused unit tests:

- userProfileDirect with Just simplexName -> wire Profile.simplexName Just
- userProfileDirect with Nothing -> wire Nothing
- userProfileDirect with an incognito Profile overlay -> wire Nothing
  (incognito identity must not leak the user's registered name)
- userProfileInGroup' pass-through
- redactedMemberProfile pass-through (forwarded member profiles)

* chat: clarify groups.simplex_name stopgap comment

Drop the asymmetric toContact comment (the mirror is now obvious post-Task-1)
and rewrite the toGroupInfo stopgap comment to reflect the actual semantics:
groups.simplex_name is per-user locally-known, mirrored into groupProfile as a
stopgap until group_profiles.simplex_name lands.

* chat: add simplex_name to contact_profiles and group_profiles

Adds nullable simplex_name TEXT column and partial UNIQUE
(user_id, simplex_name) index to both contact_profiles and group_profiles
tables. Distinct from contacts.simplex_name / groups.simplex_name
(M20260603), which carry the user's locally-known label set by the
prepare-via-name path; the new columns will carry the peer's broadcast
claim received via XInfo / XGrpInfo (wired up in following commits).

* chat: persist peer-claimed simplexName from incoming profiles

Write paths:
- updateContactProfile_' / updateGroupProfile_ now set the new
  contact_profiles.simplex_name / group_profiles.simplex_name columns
  from Profile.simplexName / GroupProfile.simplexName respectively.
- createContact_ INSERT writes Profile.simplexName to the new
  contact_profiles column (separate from the existing simplexName arg,
  which still writes contacts.simplex_name — the user's locally-known
  label).

Read paths (closing Task 2's deferred sites):
- toContact splits simplex_name reads: Contact.simplexName from
  contacts.simplex_name (existing); LocalProfile.simplexName from
  contact_profiles.simplex_name (new column).
- toGroupInfo similarly splits: GroupInfo.simplexName from
  groups.simplex_name; groupProfile.simplexName from
  group_profiles.simplex_name.
- ProfileRow / rowToLocalProfile, toContactRequest, getUserContactProfiles,
  toGroupProfile, getProfileById, groupMemberQuery, getGroupAndMember_,
  saveRcvChatItem-related quotes — all extended to read p.simplex_name
  and decode it into LocalProfile.simplexName / GroupProfile.simplexName.

Conflict handling (Decision B):
- clearConflictingContactProfileSimplexName_ / *Group* helpers do an
  atomic UPDATE-with-RETURNING that NULLs simplex_name on any other
  row in the same user that would collide on the partial UNIQUE index,
  returning the displaced row's display_name.
- updateContactProfileWithConflict / updateGroupProfileWithConflict
  bundle clear+update in one transaction.
- processContactProfileUpdate / xGrpInfo invoke the *WithConflict
  variants and emit CEvtSimplexNameConflict when a displacement
  happened (with the claiming and displaced display names).

Adds ChatEvent CEvtSimplexNameConflict and SimplexNameConflictEntity
(SNCEContact / SNCEGroup) with JSON instances and View.hs rendering.

* chat: fix review findings on simplex_name persistence

- updateUserProfile no longer writes contact_profiles.simplex_name on
  the user's own row (the column is reserved for peer claims; the user's
  broadcastable name lives on contacts.simplex_name via uct.simplex_name).
- updateMemberContactProfile_'/Reset_' now write simplex_name; new
  updateMemberProfileWithConflict / updateContactMemberProfileWithConflict
  variants run conflict-clear and return the displaced name, with
  processMemberProfileUpdate emitting CEvtSimplexNameConflict.
- createContact_ runs conflict-clear before INSERT to avoid UNIQUE
  constraint violations on first-write peer collisions, returning the
  displaced name; createPreparedContact / createDirectContact thread it
  through to APIPrepareContact and saveConnInfo XInfo for event emission.
- groups conflict-clear takes ProfileId directly (avoids the NOT IN (NULL)
  silent-noop edge case when groups.group_profile_id is ON DELETE SET NULL).
- Moves clearConflictingContactProfileSimplexName_ to Shared.hs so
  createContact_ can call it without inducing a circular import.

* chat: resolveOnUserServers iterates user SMP servers for RSLV

* chat: connectPlanName falls back to RSLV when local lookup misses

* chat: RSLV-resolved NameRecord dispatched through prepared row

dispatchResolvedRecord now picks the first nrContactLinks (NTContact) or
nrChannelLinks (NTPublicGroup) entry from the resolved record, decodes it
as AConnShortLink, fetches the short-link data, and eagerly calls
createPreparedContact / createPreparedGroup with the simplex_name set.

Returning CPContactAddress (CAPKnown ct) / CPGroupLink (GLPKnown g ...)
mirrors the local-store-hit branch of connectPlanName: hit and miss
converge on the same plan shape, so the connectWithPlan caller cannot
distinguish where the prepared row came from. Threading uses the
existing Maybe SimplexNameInfo parameter added in c6f26150 for the
local-prepare path -- no new write path or transient carrier.

Pure helper firstNameLink is extracted and exported so the link-picker
contract is testable without a DB / agent. ResolveNameTests gains five
cases covering the per-type selection, the first-link policy, and the
empty-list to CESimplexNameNotFound collapse.

* chat: regen query plans after simplex_name plumbing

* chat: register ConnectTarget + CEvtSimplexNameConflict in bot docs

Bot API docs generator failed with "Undefined type: ConnectTarget"
since f2394d121 (prior plan) flipped APIConnectPlan/Connect from
Maybe AConnectionLink to Maybe ConnectTarget without updating
bots/src/API/Docs/*. Also adds SimplexNameConflictEntity (new in
cd0de9659) and documents the CEvtSimplexNameConflict event for
peer-name displacement notifications. Regenerates the affected
markdown / TypeScript / Python artefacts.

* deps: bump simplexmq for NameRecord reshape; update consumers

simplexmq 5ee014dd reshaped NameRecord to align with the Python resolver
JSON: nrChannelLinks/nrContactLinks (lists of NameLink) became
nrSimplexChannel/nrSimplexContact (Maybe Text); nrDisplayName became
nrName; nrResolver was added; the NameLink wrapper type and nrIsTest/
nrExpiry/nrAdminAddress/nrAdminEmail fields were dropped.

Update dispatchResolvedRecord destructure and firstNameLink signature
to the new Maybe Text shape, and refresh the ResolveNameTests fixtures
and assertions accordingly.

* chat: resolveOnUserServers iterates only on transport errors

Privacy: every miss previously broadcast the candidate name to every
enabled SMP server. Now only NETWORK / TIMEOUT failures fall through
to the next server; definite resolver answers (NAME / AUTH / CMD
PROHIBITED / other ERR) stop iteration.

* chat: document why groups simplex_name index has no soft-delete filter

The contacts simplex_name index filters on (deleted = 0); the groups
index has no analogous filter because the groups table has no `deleted`
column. Groups are hard-deleted by deleteGroup, so the asymmetry is
intentional. The remaining "removed member, row retained" edge case is
flagged in the lookup comment for follow-up.

* chat: document connections.simplex_name as transient carrier

Audit flagged the column as "INSERTed but never UPDATEd". This is by
design per the prior plan's connect-via-plan flow: the column is a
transient carrier between connection-creation and contact-creation.
After the Contact row is created via XInfo handling, contacts.simplex_name
is the source of truth and the connections value is a historical snapshot.
Documents the intent so future readers don't reflag it.

* chat: extract surfaceSimplexNameConflict helper

Six call sites duplicated the same forM_ ((,) <$> claim <*> displaced)
shape emitting CEvtSimplexNameConflict. Extract to a single helper so
future call sites don't drift on whether to emit, and so the conflict
event shape change (post-Task-3 SimplexNameConflictEntity split into
SNCEContact / SNCEGroup) propagates through one site.

* chat: APIVerifySimplexName command + CEvtSimplexNameUnverified warning

Addresses the TOFU vulnerability where peer-claimed simplex_name was
accepted unverified. Adds:

- contacts.simplex_name_verified_at + groups.simplex_name_verified_at
  (M20260606_simplex_name_verified)
- APIVerifySimplexName ChatRef command: RSLV-resolves the claimed name
  and compares the resolved link to the peer's stored connection link;
  on match writes verified_at and emits CEvtSimplexNameVerified;
  on mismatch emits CEvtSimplexNameVerifyFailed
- CEvtSimplexNameUnverified passive warning emitted on incoming XInfo /
  XGrpInfo when a name claim arrives without a current verification
- updateContactProfileWithConflict / updateGroupProfileWithConflict
  clear simplex_name_verified_at whenever the peer's claim transitions
  (any value change including Nothing<->Just): the prior verification
  was bound to the prior claim.

UI can surface the unverified indicator next to a contact / group's
name, and prompt the user to invoke the verify command. This shifts
the security model from "TOFU + last-writer-wins" to "TOFU + on-demand
RSLV verification".

* chat: register APIVerifySimplexName + verify events in bot docs

ebe90f716 added the verify command + events + SimplexNameVerifyFailReason
type without touching bots/src/API/Docs/. Mirrors commit 0d7ea8061 which
addressed the same gap for ConnectTarget. Regenerates the affected
markdown / TypeScript / Python artefacts.

* chat: bump simplexmq pin + document cross-table simplex_name discriminator

Pin bump 5ee014dd -> c9c2d19 picks up the 8 simplexmq commits since the
last bump (parseBare lowercase fix, forwarded-param cleanup, ServerTests
+ agent end-to-end tests, TldRegistries removal, SNRC ABI decoder,
NameRecord/NameOwner module extraction).

Adds a brief comment on clearConflictingContactProfileSimplexName_
explaining why the audit's flagged cross-table collision (between
contact_profiles.simplex_name and group_profiles.simplex_name) is
structurally impossible: SimplexNameInfo's strEncode prefixes contact
names with '@' and group names with '#', so the stored bytes never
overlap between the two tables.

Query-plan regen deferred (the test is non-deterministic in CI / dev
sandbox — see prior 6c990696c).

* deps: bump simplexmq for HTTP resolver; adapt NameRecord consumers

simplexmq 92b3d049 reshaped NameRecord text fields from Maybe Text to
Text (empty string sentinel). Adapt firstNameLink to take Text directly
and treat T.null as "absent". dispatchResolvedRecord destructure
unchanged; passes the text values straight through. apiVerifySimplexName
switches from Just/Nothing pattern to a T.null guard with the same UX.
Test fixtures updated.

* deps: bump simplexmq for multi-link NameRecord; adapt consumers

* core: treat RSLV CMD UNKNOWN as no name-resolution support

* core: fix contact-by-connection query missing simplex_name_verified_at

* deps: update sha256map for simplexmq f555e9af pin

* core: iterate past RSLV-unsupported name servers

* core: filter RSLV servers by operator enablement

* core: align resolver docs/tests with RSLV errors

* deps: bump simplexmq to df1aa24c

* refactor(names): agent resolution + one error type

Adopt the simplexmq names rework (PR #7045): name resolution is now
owned by the agent (resolveSimplexName picks a names-role server), so
the chat-side iteration is removed - delete ResolveError,
iterateResolvers, resolveOnUserServers, enabledSMPServersForUser and
resolveErrorToChatError.

One error type: resolver/agent failures flow through ChatErrorAgent;
remove the CEvtSimplexName* events, SimplexNameVerifyFailReason,
SimplexNameConflictEntity and CESimplexNameResolverUnavailable.
APIVerifySimplexName returns CRSimplexNameVerified (verified::Bool),
mirroring CRConnectionVerified. connectPlan handles the name target
directly; updateProfile WithConflict aliases collapsed into the plain
functions.

Add the per-operator "names" SMP server role (migration
20260612_smp_role_names, official operator on by default) feeding
ServerRoles.names -> UserServers.nameSrvs.

Bump simplexmq pin to ce69adfd and regenerate sha256map.nix.

* fix(store): match chat_schema.sql to sqlite 3.46+ indent

The schema-dump test renders the partial-index WHERE via the sqlite3
CLI; sqlite >=3.46 wraps a multi-condition WHERE onto two lines
("IS NOT NULL" + indented "AND ...") where 3.45 kept it on one. The
committed schema was generated with 3.45, so CI (newer sqlite) failed
the comparison on idx_contacts_simplex_name. Regenerated with the
newer formatter; only that one WHERE clause changes.

* feat(operators): warn when no server resolves names

Mirror USWNoChatRelays: validateUserServers emits USWNoNamesServers
when no enabled server of an enabled operator carries the SMP names
role. noNamesServersWarns is self-contained with local predicates,
matching the sibling noChatRelaysWarns; noServersErrs is untouched.

* test(operators): expect USWNoNamesServers warning in no-servers cases

* fix(store): single-line simplex_name WHERE to match CI sqlite (<=3.45)

* chore: bump simplexmq pin to 6843b14c

* refactor(store): consolidate names migrations into one

Unshipped feature - merge the four incremental simplex_name migrations
(0603/0604/0606/0612) into a single M20260603_simplex_name. The combined
UP applies the ALTERs/indexes in the same order, so the resulting schema
is byte-identical (verified by SchemaDump on SQLite and pg_dump on Postgres).

* update simplexmq

* plan for name resolution

* update types and schema

* simpler resolution, name proofs

* simplexmq

* generate bot types, schema, unStrJSON, fix tests

* ad hoc link comparison, create short link

* update simplexmq

* remove same link, use simplexmq instead

* split verify API for contacts and public groups

* remove comment

* simplify warnings

* test: remove unused

* remove cute language

* type name

* remove spurious comments

* refactor setting user name

* refactor setting user name

* remove trivial tests

* refactor

* remove tests using pre-short-link addresses

* rename

* move names

* bots api

* refactor more

* refactor

* refactor to another type

* load own names

* read short links when looking up by name

* renames

* refactor verification

* mapM_

* update api types

* change field for name

* rename columns

* api types, schema

* renames

* fix links

* simplify

* remove comments

* rename fields

* simplify

* remove proof from channel addres

* refactor

* name resolution test

* change tests

* fix tests

* fix tests

* fix plan for names

* test

* test verification status

* bot api

* fix tests

* update bot api types

* query plan

* add api for setting public group access

* android, desktop, ios: connect via SimpleX name (#7068)

* android, desktop, ios: connect via SimpleX name

* android, desktop, ios: open known contact on name lookup; surface prepared contact

Name search opens the contact (not list-filter); resolved/prepared contacts and groups are added to the chat list so they're visible and openable. Kotlin compile-verified; iOS edits pattern-matched, pending Xcode build.

* feat(names): UI names role + agent NAME error

Parity with the core names rework (#7045):

- Add `names` to ServerRoles (Android + iOS) and a per-operator
  "To resolve names" toggle under the SMP section (xftp has no names
  role; the shared ServerRoles field stays false there).
- Mirror the new agent error: NameErrorType + a NAME case on both
  AgentErrorType and ProtocolErrorType (the SMP ErrorType mirror), so
  the new SMP/agent NAME errors decode instead of crashing the decoder.
- Remove ChatErrorType.SimplexNameResolverUnavailable (deleted in core)
  and repoint its "name resolution unavailable" alert to the agent
  NAME NO_SERVERS error, reusing the existing strings.

Android (multiplatform) compiles clean; iOS mirrors the same changes
(builds in Xcode).

* feat(names): UI warning when no server resolves names

Mirror core USWNoNamesServers: add the NoNamesServers variant to
UserServersWarning (Kotlin sealed class + Swift enum) and its
globalWarning / globalServersWarning branch, rendered by the existing
ServersWarningFooter / ServersWarningView. Matches the noChatRelays
warning exactly.

* fix(servers): show all validation errors and warnings, not just the first

globalServersError/Warning returned only the first entry, so a second
warning (e.g. no names servers behind no chat relays) or a second error
(e.g. no XFTP servers behind no SMP servers) was never displayed. Make
them return all entries (globalServersErrors/Warnings) and render one
footer row each, across the three combined-footer views. Per-protocol
SMP/XFTP footers are unchanged.

* docs(names): add SimpleX name UI plan

* feat(names): add name model fields + SimplexName helpers

* feat(names): verify + set-name API & responses

* docs(names): bump core sync to 5008b4e62

* feat(names): show name + verification on chat info

* feat(names): add Verify SimpleX names privacy toggle

* feat(names): add set-name screens (user + channel)

* update ui

* fix kotlin

* fix codable

* fix ios

* fix errors

* api in UI

* send name as string in protocol

* update simplexmq, capitalize

* verify that name is in profile for own and known contacts and channels as condition of name resolution

* update simplexmq

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* add log

* bot types

* finalize renames, ui alerts

* more renames

* kotlin alerts

* show set name

* alerts

* texts, icons, footers

* move JSON parsing to a persistent thread with large stack

* simplexmq

* use uikit alerts

* remove comment

* move name verification to more privacy

* change icon on verify names setting

* verify name proof

* nix shas

* show verified names

* better alert when saving names

* revert breaking field name change

* simplexmq

* clean up

* remove unused

* remove empty line

* fix error

* simplify

* use domain without prefix in profiles and in database, rename fields and types

* rename types

* fix JSON name

* pass verified domain to prepare API

* fix prepare api

* fix ios encoding

* enable name resolution via Flux servers

* fix test

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
2026-07-03 12:51:52 +01:00

31 KiB
Raw Blame History

SimpleX names — simplification plan

Status: design agreed while reviewing branch sh/namespace. This supersedes the name handling currently on that branch. Line refs are to the working tree at review time; re-check before editing.

1. Goal

Reduce the feature to the minimum coherent, secure shape:

  • One name per entity, on the profile (the entity's claimed identity), typed Maybe SimplexNameInfo. No second "locally-known" copy on the entity row.
  • A local verification status as a 3-state Maybe Bool (not a timestamp): not-attempted / failed / verified. A failed check never blocks connecting.
  • Connect-by-name requires the entity to claim the name, and the result is created as verified.
  • The proof (address-key signature, context-bound) is in scope for link contexts — connect-by-name, 1-time invitations, contact addresses, channel join links — so those names are verifiable in this release. Only a name with no link context (a group member, or a name on an XInfo profile over an established connection) is deferred: stored, but not shown until that gap closes.

Removed from the current branch: the *.simplex_name entity columns (the "locally-known/ct" copy), the connections.simplex_name carrier, the four partial UNIQUE indexes + "newer-claim-wins" clearing, and the _verified_at timestamps.

2. Why (trust model)

A name resolves to a link via the agent (resolveSimplexName); a NameRecord has lists of links (nrSimplexContact, nrSimplexChannel), so verification matches against any of them. "Match any" is safe only because the namespace is an on-chain, ENS-style registry: nrOwner/nrResolver are Ethereum addresses (Names/Record.hs:22-23,36), so each name has a single owner who sets all its links. An attacker can register their name → your address (the offensive-name case, handled by the claim check below) but cannot add a link to your name — on-chain ownership blocks impersonation. Everything here depends on that; if names were not single-owner, "match any" would be exploitable.

The registrant of a name controls what it points to, with no proof that the target address agreed to it — anyone can publish @offensive → your address or #offensive → your channel. Therefore:

  • One-directional verification ("does resolve(name) equal a stored link") is insufficient: it confirms the registrant's assertion, not the address owner's.
  • A profile claiming a name (and even a link) proves nothing — anyone can copy a link into a profile. Control of a link is proven only by an actual connection/join through it.
  • Sound verification is the intersection: resolve(name) → link, and the entity advertises that name in its profile (it claims the name), and that link is one we connected/joined through (control-proven).
  • Verifying a name without having connected through its link needs a signature by the address key over the name, bound to the presentation context (§4.8). This is in scope for link contexts: a 1-time invitation includes such a proof bound to the invite, so resolving the name → address → address key verifies it.

Consequence: names are verifiable in this release for channels (join link), contacts connected via their address/name (the link connected through), and 1-time-invite contacts (the in-scope address-key proof). The only case still deferred is a name with no link context at all — a group member, or a name on an XInfo profile over an established connection — which stays stored-but-not-shown.

3. Current branch state (to be changed)

Migration M20260603_simplex_name currently adds, on both SQLite and Postgres:

  • contacts.simplex_name, contacts.simplex_name_verified_at
  • groups.simplex_name, groups.simplex_name_verified_at
  • connections.simplex_name
  • contact_profiles.simplex_name, group_profiles.simplex_name
  • UNIQUE indexes idx_contacts_simplex_name, idx_groups_simplex_name, idx_contact_profiles_simplex_name, idx_group_profiles_simplex_name
  • server_operators.smp_role_names (= 1 for 'simplex')

…and threads two names per entity through the types: Contact.simplexName / GroupInfo.simplexName (from *.simplex_name, "locally known"), and LocalProfile.simplexName (from *_profiles.simplex_name, "peer claim"), plus a verified_at timestamp on the entity. apiVerifySimplexName verifies the entity (ct) name against the profile's self-asserted contactLink for contacts (wrong link), and against preparedGroup.connLinkToConnect for groups (correct). The connections.simplex_name carrier is plumbed through createConnection_ but never written (all callers pass Nothing).

4. Target design

4.1 Name type and JSON encoding

The name type is SimplexNameInfo (simplexmq Simplex/Messaging/SimplexName.hs:37), which has:

  • StrEncoding (SimplexName.hs:89) — canonical simplex:/name@… / #… string,
  • a text ToField (SimplexName.hs:146) — stores as TEXT,
  • a JSON object instance: $(J.deriveJSON defaultJSON ''SimplexNameInfo) (SimplexName.hs:154) → {nameType, nameDomain}.

Keep the object JSON — the UI/API needs the structured form (it reads the name off LocalProfile, CRSimplexNameVerified, …). The conflict is only on the wire: PublicGroupAccess.groupDomain is a released field typed Maybe Text (a JSON string), so the wire form of the name must stay a string.

Resolve by wrapping only the wire fields with simplexmq's generic newtype StrJSON (Simplex/Messaging/Encoding/String.hs:265), whose ToJSON/FromJSON go through StrEncoding → a JSON string (String.hs:267272; idiom deriving (ToJSON, FromJSON) via (StrJSON "X" T)):

  • wire (Profile, GroupProfile/PublicGroupAccess): Maybe (StrJSON "SimplexName" SimplexNameInfo) → JSON string, byte-identical on the wire to the released Maybe Text.
  • local/UI (LocalProfile): Maybe SimplexNameInfo, unwrapped → JSON object.

Wrap/unwrap at the ProfileLocalProfile boundary (toLocalProfile / fromLocalProfile). SimplexNameInfo's own JSON instance is untouched, so CRSimplexNameVerified and any other UI-facing use stay object.

Inherent asymmetry: there is no LocalGroupProfile, so the channel name reaches the UI as a string (via GroupProfile, which is StrJSON), while the contact name reaches it as an object (via LocalProfile). If the UI needs the channel name as an object too, add a decoded field on GroupInfo (follow-up). The bot-API binding generator must render StrJSON-wrapped fields as string.

DB stays TEXT: ToField (SimplexName.hs:146) on write; on read a hard FromField SimplexNameInfo (add it — SimplexName.hs:141-145 says to define it "when a consumer requires the row-fail behaviour"), so an invalid stored name fails the row — matching the wire, where a name that won't strDecode fails the profile. No soft-decode (decodeSimplexName dropped for the name columns).

4.2 Types (field changes)

  • Profile.contactDomain :: Maybe (StrJSON "SimplexName" SimplexNameInfo) — NEW; JSON string (§4.1). The entity's advertised contact name. (Replaces the branch's Profile.simplexName.)
  • Profile.contactDomainProof :: Maybe ClaimProof — NEW; a flat sibling of contactDomain/contactLink, a wire profile field like Profile.badge. The stored own profile (contact_profiles) has the name but no proof; the proof is generated and added to the outgoing profile at send/save (§4.8), exactly as the badge is. PHSimplexLink (verifiable) when the profile is saved to a contact address / 1-time invite; PHTest over an established connection (the gap).
  • LocalProfile.contactDomain :: Maybe SimplexNameInfo (unwrapped → object JSON) and LocalProfile.contactDomainVerification :: Maybe Bool — local status, not on wire Profile. toLocalProfile unwraps the StrJSON; fromLocalProfile re-wraps contactDomain and drops the status (mirrors how localBadge's status is dropped).
  • PublicGroupAccess.groupDomain :: Maybe (StrJSON "SimplexName" SimplexNameInfo) — RETYPE from Maybe Text (Types.hs:857); JSON string, wire-identical to the released text (§4.1). Owner-set, broadcast in XGrpInfo. Only public, relay-backed groups (groups.use_relays) can have a name — they have a channel join link to resolve to and an owner key (groups.member_priv_key, chat_schema.sql:193) to sign with; p2p groups have neither. (contactDomain on a member is a contact attribute, unaffected.)
  • GroupInfo.groupDomainVerification :: Maybe Bool — local status, read from the groups table (there is no LocalGroupProfile, and GroupProfile is the wire type, so the status cannot be sent with the profile).
  • DROP Contact.simplexName, GroupInfo.simplexName, Connection.simplexName, and both *VerifiedAt timestamps.

Asymmetry, intentional: contact name + status both live on contact_profiles (exposed via LocalProfile); the group name lives on group_profiles (exposed via GroupProfile/PublicGroupAccess) but its status lives on groups (exposed via GroupInfo). This is forced — group_profiles is the shared wire profile with no local columns, while contact_profiles already holds local state (local_alias).

4.3 Schema — rewrite M20260603_simplex_name (branch unreleased)

Add:

  • contact_profiles: contact_domain TEXT, contact_domain_verification (nullable INTEGER SQLite / SMALLINT Postgres).
  • groups: group_domain_verification (nullable INTEGER/SMALLINT).
  • server_operators.smp_role_names (= 1 for 'simplex') — keep.
  • user_contact_links: the contact-address root signing key (BLOB, mirroring groups.root_priv_key) — captured from the 2-step short-link creation — so the contact-address / 1-time-invite proof can be signed chat-side. (Not present today.)

No DB change for group_profiles.group_domain — it already exists (from M20260515_public_group_access); only the Haskell type and JSON change.

Remove (vs the current branch migration): contacts.simplex_name, contacts.simplex_name_verified_at, groups.simplex_name, groups.simplex_name_verified_at, connections.simplex_name, contact_profiles.simplex_name, group_profiles.simplex_name, and all four UNIQUE indexes. No name uniqueness is enforced at the DB level — identity comes from verification, not a constraint.

Verification column decode: Maybe BoolInt → Maybe Bool (NULL = not attempted, 0 = failed, 1 = verified).

4.4 Storage functions (Store/Shared.hs, Store/Direct.hs, Store/Groups.hs)

  • Read the name columns as Maybe SimplexNameInfo via the hard FromField (§4.1) — an invalid name fails the row; no soft decodeSimplexName.
  • toContact / toGroupInfo: read contact_domainLocalProfile.contactDomain and contact_domain_verificationLocalProfile.contactDomainVerification; group_profiles.group_domainPublicGroupAccess.groupDomain and groups.group_domain_verificationGroupInfo.groupDomainVerification. Delete the ct/cp split and the entity-simplex_name reads.
  • createContact_ / createGroup_ / createPreparedContact / createPreparedGroup: set the name on the profile columns only; drop the entity-simplex_name argument and the connections.simplex_name carrier param on createConnection_.
  • updateContactProfile / updateGroupProfile: write contact_domain / group_domain from the received profile. Reset the verification to NULL (not-attempted) only when the name changes; an XInfo/XGrpInfo with the same name keeps the existing status (a verified name stays verified), exactly as a badge does. No conflict clearing (no UNIQUE index).
  • getContactBySimplexName / getGroupIdBySimplexName: look up by the verified profile name (the name column joined with verification = Just True); on a miss or unverified, fall through to resolve-and-connect. Consistent with the existing by-address lookup getContactViaShortLinkToConnect (Direct.hs:963), which matches the link with no verification check — a link is the identity, a name is a claim that only becomes a usable pointer once verified.

4.5 Redaction (Library/Internal.hs:1246)

redactedMemberProfile :: GroupInfo -> GroupMember -> Profile -> Profile
redactedMemberProfile g m Profile {, contactLink, contactDomain} =
  let allowDirect       = groupFeatureMemberAllowed SGFDirectMessages m g
      allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks  m g && allowDirect
   in Profile { 
              , shortDescr         = removeSimplexLink =<< shortDescr       -- via allowSimplexLinks
              , contactLink        = if allowSimplexLinks then contactLink   else Nothing
              , contactDomain      = if allowDirect       then contactDomain else Nothing
              , contactDomainProof = Nothing }  -- member profiles are contextless; never include a proof
  • allowDirect is the single primitive (the DirectMessages permission); it controls the name and is reused inside allowSimplexLinks. No allowName flag, no second lookup.
  • Behavior changes vs current: contactLink flips from unconditionally dropped to controlled by allowSimplexLinks (a member's contact address becomes visible whenever links+DMs are allowed — the meaning of "links allowed"); the name follows the looser allowDirect. Rationale: a link is one-tap-to-connect (low friction), a name only resolves if the recipient deliberately looks it up (higher friction), so a group can forbid links yet allow name discovery, with "DMs allowed" the floor for both.
  • Signature takes (GroupInfo, GroupMember) and derives both flags inside, so the rule lives in one place. Callers pass (g, m); the own-profile path passes (g, membership g) — behavior-preserving because groupFeatureUserAllowed f g ≡ groupFeatureMemberAllowed f (membership g) g (both reduce to groupFeatureMemberAllowed' f (memberRole (membership g)) (fullGroupPreferences g), Types.hs:646652).
  • Collapses groupUserAllowSimplexLinks (Types.hs:656) and the pre-computed allowSimplexLinks wiring at the call sites (Internal.hs:1244, Subscriber.hs:842,2817,3239, Commands.hs:3748,4057). Commands.hs:3748 has Maybe GroupInfo → "no group ⇒ no redaction" at the call site.

4.6 Resolution + verification (Library/Commands.hs)

Two cases that differ in whether verification can fail:

Connect-by-name (connectPlanName / dispatchResolvedRecord) — verification is a precondition of connecting. After decoding the resolved short link's embedded profile, add the claim check: require that profile's contactDomain / groupDomain to equal the resolved name, else fail with CESimplexNameNotFound ("name unknown") and do not connect. (The current branch decodes the profile but never compares the name — this is the missing check.) On success the prepared contact/group is created with the name on the profile, connLinkToConnect = the resolved link, verification = Just True (created as verified). There is no "failed" outcome here — failing to resolve or to claim the name just means no connection.

Connected NOT by name (via an address link or a 1-time link) — the peer's profile may claim a name; it starts unverified (Nothing). Verification is post-hoc and non-blocking: keep apiVerifySimplexName (/_verify simplex name).

  • Contacts verify by a single path — check contactDomainProof (§4.8): resolve the claimed name → its link, validate the owner chain and select the key by the proof's linkOwnerId (that owner's ownerKey, or the root key if Nothing — the usual contact-address case), check the ClaimProof signature over name <> presHeader, and check the proof's presHeader link == preparedContact.connLinkToConnect (not profile.contactLink — the branch bug). Contact addresses and 1-time invites both include the proof.
  • Channels verify by presence / link-match: resolve(#name) includes preparedGroup.connLinkToConnect (the join link), whose owner-signed data already has groupDomain (no ClaimProof — §4.8). Result is Just True (holds) or Just False (fails) — and Just False must NOT prevent the connection, exactly as a failed badge verification doesn't. This is why the status must be 3-state (Nothing not attempted / Just False failed / Just True verified). Names that stay Nothing have no proof/link context — group members, and names on an XInfo profile over an established connection.

Open option: auto-verify on connect (run the same non-blocking check automatically when connecting via address/1-time link), instead of only on demand. Either way the status stays 3-state — auto-verification can fail without blocking.

Keep the pure helpers firstNameLink (per-type link pick, cross-type rejection) and linksMatch (scheme-normalized compare).

4.7 Display (View.hs)

A name is shown when there is a proof to verify, together with its status — verified, failed, or not-yet-verified. All three states are shown (a failed or pending check still shows the name, flagged), so the user sees the name and its trust level rather than a silent omission. Rendering those three states is out of scope for this PR (UI work), but the core stores the data — the 3-state status and the proof — to drive it. A name with no proof to verify — a group member, or a name on an XInfo profile over an established connection — is not shown.

4.8 Proof — a flat profile field, signed by the address key, context-bound

A name resolves to a contact address (the persistent identity link). The proof asserts "the owner of that address asserts this name", so it is signed by the address's key (the resolved address's own key) — not by the per-connection or 1-time-link key. Every proof is tied to the link it's shown through, so a proof made for one link can't be reused on another; these are distinct proofs. Store the link in a presentation header: rename BadgePresHeader (Badges.hs:212) → ProofPresHeader (now shared by badge and name proofs) and add a PHSimplexLink AConnShortLink constructor. AConnShortLink (Agent.Protocol.hs:1536, forall m. ConnectionModeI m => ACSL (SConnectionMode m) (ConnShortLink m)) is the existing existential, so the context is either a 1-time invitation or a contact address. The signed payload includes this header, and the verifier compares the header's link against the link the proof is presented through (the current invite/address) — that comparison is what makes a proof non-replayable across links. The tag enum (Badges.hs:200), the StrEncoding (:216), and badgePresHeaderAccepted (:226, → proofPresHeaderAccepted) each gain the new variant.

Type & wire — a JSON object like BadgeProof (Badges.hs:393):

data ClaimProof = ClaimProof
  { linkOwnerId :: Maybe OwnerId,          -- which owner signed; Nothing = root key (see below)
    presHeader  :: ProofPresHeader,         -- context: PHSimplexLink <this invite> | PHTest
    signature   :: C.Signature 'C.Ed25519   -- by that owner's key, over: smpEncode name <> smpEncode presHeader
  }

The signature is by the key of the signer's owner identity in the link's owner chain (OwnerAuth, Agent/Protocol.hs:1829); ClaimProof has linkOwnerId :: Maybe OwnerId (OwnerId, :1827) to name it, so the verifier checks exactly that key (no iterating the owner list):

  • Channels (which can have owners other than the address) sign with the user's owner key, linkOwnerId = Just oidnot the root key — groups.member_priv_key (chat_schema.sql:193).
  • A contact address has a single owner = its creator, so it signs with the root key, linkOwnerId = Nothing.

The root key (ShortLinkCreds.linkPrivSigKey/linkRootSigKey, :1482-83) otherwise only authorizes owners (validateOwners/validateLinkOwners, :18461859); Nothing (root) is allowed at validation. Both keys live chat-side so signing is in the chat layer — but only channels store theirs today (groups); a contact address (user_contact_links, :386) has no key column, so we must add one (mirroring groups.root_priv_key), captured from the 2-step short-link creation, to sign the contact-address / 1-time-invite proofs. Signed payload = smpEncode name <> smpEncode presHeader (name from the profile's contactDomain). presHeader serialises as its StrEncoding string, signature base64url.

Per presentation context:

  • Contact address (the name's own resolved link): presence in the address link's owner-signed data already proves the name — but we include the explicit ClaimProof here too (bound to the address) so contact verification is one uniform path (always check the proof) rather than presence-for-addresses / proof-for-invites. The address can also serve as the badge proof's context.
  • 1-time invitation: include a proof signed by the address key, bound to the 1-time link as context. Feasible now — the 1-time link is a unique, single-use context; no general mechanism required. This is what makes a 1-time-invite contact verifiable.
  • XInfo over an established connection: no link context → the step that adds the proof sets PHTest (unbound) → unverifiable → not shown. Left for later, same as the badge's PHTest. (For group members the proof is dropped entirely by redaction, §4.5.)

Home: inside the profileProfile.contactDomainProof :: Maybe ClaimProof, a flat sibling of contactDomain/contactLink, exactly like Profile.badge. It is not stored on the own profile; like the badge, it is generated fresh and added to the outgoing profile at send/save — when the profile is sent to a peer (XInfo) or saved to a link — by signing name <> presHeader with the address root key and setting the destination as the presHeader context. Because presentUserBadge (Internal.hs:2037) already adds the badge proof to the outgoing profile at both peer-sends and link-data writes, the name-proof step belongs in the same function. The context decides whether a proof is useful: saving to a contact address / 1-time invite sets PHSimplexLink(that link) (verifiable — the in-scope cases); an established-connection peer-send sets PHTest (unbound — left for later, same as the badge). The receiver verifies and stores the 3-state status on LocalProfile.contactDomainVerification, as localBadge holds the badge status. Connect-by-name is created as verified.

Scope: the useful (PHSimplexLink) proofs — set when the profile is saved to a contact address or 1-time invite — are in this change; the verifier resolves the name → address → address key → checks the signature and the presHeader link. Channels use presence (below). The contextless established-connection case (a PHTest name proof) and group members are left for later.

Badges stay in the profile — person-scoped, presented per connection (presentUserBadge, Internal.hs:2037), including over established connections via XInfo. They share the presentation-header context type with name proofs but sign with the badge credential key, not the address key. Two scopes, two homes.

The shared step sets both proof kinds the same way: PHSimplexLink(link) when the destination is a link (contact address, 1-time invite — verifiable), PHTest over an established connection (unbound — the gap). So name proofs and badges both use PHTest only for the contextless established-connection case.

No group proof field: a channel is always joined via its join link (its own address), whose owner-signed data already has groupDomain, and there is no "advertised link ≠ resolved address" case for channels — so channels verify by presence/link-match and need no ClaimProof. (If full symmetry is wanted later, add GroupProfile.groupDomainProof the same way.)

4.9 Set-name API (in scope — currently missing)

Names are pre-registered out of band — the app does not call RNAME. The API only verifies the name and adds it to the profile. The user must be able to add/change/remove their own name from the UI; the branch has no command for the contact name.

  • Contact name — add APISetUserName :: Maybe SimplexNameInfo -> ChatCommand (Nothing clears). On set:
    1. Require an address — fail if none (the UI won't offer the action without one).
    2. Require it to be a short link — if only a long link exists, create the short link (the name resolves to a short link, and the check below is short-link-based).
    3. Ensure that short link is in the profile (contactLink) — add it if missing.
    4. Verify: resolve the name and compare the short link it points to against the profile's contactLink; fail if they don't match (name not preregistered to this address).
    5. Set LocalProfile.contactDomain and re-publish the contact-address link data. The ClaimProof is added when the profile is saved to that link (the send/save proof-adding step, §4.8) — not produced by the API itself. (Steps 14 are the verify; this step is set + re-publish.) Needs a ChatCommand constructor + parser + handler.
  • Channel name (public, relay-backed groups only) — a separate APISetPublicGroupName, parallel to the contact one (not folded into SetPublicGroupAccess): same require-address / require-short-link / link-in-profile / verify / set-groupDomain flow against the channel's join link. Rationale: it mirrors the contact API, and the name's fail-able verify + preconditions don't mix cleanly with the plain web=/embed=/domain_page= writes. Drop domain= from SetPublicGroupAccess (Commands.hs:5461) so there's a single verified path; factor out the shared GroupProfile-update + XGrpInfo broadcast so both commands reuse it. Verify is TLD-dependent: resolve+compare for TLDSimplex, a different/no check for TLDWeb (web domains don't resolve through the namespace).
  • The own name has no stored verification status — the verify step checks it at add time; the 3-state status is only for peers' names. No RNAME wiring (out of scope).

5. Removal checklist (from the current branch)

  • Contact.simplexName, GroupInfo.simplexName, Connection.simplexName.
  • *VerifiedAt timestamps (→ Maybe Bool status fields).
  • connections.simplex_name column + the createConnection_ carrier param + the XInfo carrier consumption in Subscriber.hs.
  • contacts.simplex_name, groups.simplex_name columns.
  • The four partial UNIQUE indexes + clearConflictingContactProfileSimplexName_ / clearConflictingGroupProfileSimplexName_ + their call sites.
  • getContactBySimplexName / getGroupIdBySimplexName against entity columns (re-point or remove per 6.b).

6. Resolved decisions

a. Wire-string encoding (§4.1): SimplexNameInfo keeps object JSON; wire fields are wrapped in StrJSON (string), LocalProfile stays unwrapped (object). Channel name reaches the UI as a string (via GroupProfile) and that is fine — no decoded-object field on GroupInfo (no reason to re-connect to a channel you're in; channel names are only shown verified). The object form matters for contacts (connect-from-groups, sharing), which LocalProfile provides. Residual chore: teach the bot-API binding generator to emit string for StrJSON fields and pick the StrJSON name Symbol. b. Lookup (§4.4): re-point getContactBySimplexName (its one caller is connect-by-name, Commands.hs:4241) to the verified contact_profiles. contact_domain; miss/unverified ⇒ resolve-and-connect. Keeps the no-network shortcut for already-known contacts. (getGroupIdBySimplexName has no external caller — drop it.) c. Proof (§4.8): a flat Profile.contactDomainProof :: Maybe ClaimProof, a wire profile field like Profile.badge. Not stored on the own profile; generated fresh and added to the outgoing profile at send/save (peer XInfo or save-to-link) by the same function as the badge (presentUserBadge, which already runs at peer-sends and link-data writes). Signed by the signer's owner-identity key — a channel's owner key (groups.member_priv_key, linkOwnerId = Just oid) or a contact address's root key (sole owner, linkOwnerId = Nothing) — over name <> presHeader; linkOwnerId selects the verification key (Nothing = root, allowed at validation). PHSimplexLink for address/invite saves, PHTest over established connections (the gap). Verify checks the signature and presHeader's link == the link actually used (connLinkToConnect). Channels use presence (owner-signed link data). Gap: the contact-address key isn't stored chat-side today (user_contact_links has no key column) — add one to sign chat-side. Receiver status on LocalProfile.contactDomainVerification. d. redactedMemberProfile contactLink exposure (§4.5): intended — a member's contact address becomes group-visible when links + DMs are allowed. e. Verify command + status (§4.6): keep apiVerifySimplexName — required to verify a claimed name for entities connected not by name (address / 1-time link). Status is 3-state because a failed name (or badge) verification must not block the connection; connect-by-name is the only created-as-verified path (and there a failure to resolve or to claim the name means no connection, not a failed state). Auto-verify on connect (vs. on-demand only) is left open; it doesn't change the 3-state requirement.

7. Rollout & scope

The claim check (§4.6) — a name resolves only if the resolved link's own data claims it — is the anti-stray-names protection and must ship regardless: a name someone registers against a real address must not "work" without that address owner's agreement. It needs no signature (the address-case presence suffices), so it lands with the core change.

The signed proof (§4.8) can ship in the same release (add + verify together) or be staged — implemented at the core level with the user-facing name-addition hidden in the UI until ready. Either way it stays a core change.

The ProofPresHeader rename + PHSimplexLink + letting badges opt into the link context ripples through the badge code — accepted, and bounded:

  • Badges.hs: BadgePresHeaderProofPresHeader, BadgePresHeaderTagProofPresHeaderTag, badgePresHeaderAcceptedproofPresHeaderAccepted (:200, :212, :216, :226); add the PHSimplexLink AConnShortLink tag/constructor/StrEncoding/accepted-case; badgeProof (:314) takes the renamed type.
  • presentUserBadge (Internal.hs:2037) + its ~15 call sites — the shared point that adds proofs to the outgoing profile (already runs at peer-sends and link-data writes): generalize it to set both the badge proof and the name ClaimProof onto the outgoing profile, using PHSimplexLink link where the destination is a link, PHTest otherwise.

The pure rename can land as a standalone prep commit ahead of the proof; the PHSimplexLink wiring + name proof land with the proof work.