← sti.care labs

sti.care: Design Doc

The "how." Complete product + technical spec for the MVP. Pairs with Philosophy (why) and Decisions (what). Not legal advice. Absorbs the earlier aliases/linking spec.


1. The badge

Canonical definition. Blue ("up to date") requires all three: (1) tested within 90 days, and "tested" means the standard core panel: HIV, syphilis, gonorrhea, chlamydia (the set every testing center offers, so requiring it is not an access tax), with every exposed site actually covered (see the site rule below); (2) clear: no current active non-HIV STI, the only acceptable positive being syphilis serology from prior treated history (not current infection); (3) active HIV protection via at least one of three routes: on PrEP, undetectable, or a public commitment to condoms-always. Gray = everything else. Detectable HIV is a hard blocker, independent of route: because the "clear" axis above is scoped to non-HIV STIs, a positive and not-undetectable HIV status forces gray on its own, regardless of any route, condoms-always included. For an HIV-positive person the only route to blue is undetectable; condoms-always and PrEP never override a detectable result.

The site rule (why "tested" is honest, computed on-device, never displayed). Gonorrhea and chlamydia must be checked at each site a person is exposed at: pharyngeal (oral), rectal (receptive anal), urogenital. A urine test alone misses most extragenital infection. Blue therefore requires, per site, "tested clear OR not exposed there." Someone who doesn't do oral satisfies the pharyngeal requirement by not exposed (no irrelevant swab forced); someone who does satisfies it by swabbed and clear. This keeps "tested" honest without paternalism (no test demanded for an unused site) and without privilege bias. Critically, the per-site reasoning is computed on-device and NEVER displayed: a viewer only ever sees blue/gray. Surfacing which sites were tested would leak behavior (testing pharyngeal implies oral sex, rectal implies receptive anal), re-creating exactly the inference the model fights. The site logic produces the badge; it never produces a viewer-facing fact.

Displayed labels (what a viewer actually reads). The blue headline states the route that earned blue, once, so "HIV prevention" appears in exactly one place and means exactly one thing (no title-vs-tag double meaning). It reads "Tested & on HIV prevention" when the route is PrEP or undetectable (the umbrella, camouflaging which), or "Tested & always uses condoms" when the route is the public condoms-always commitment. Precedence: if a person has both the PrEP/undetectable umbrella and condoms-always, the headline shows "Tested & on HIV prevention" (the umbrella always wins; making the headline depend on a PrEP/U=U person's condom use would leak behavior). The route therefore does not also appear as a redundant tag; tags carry only the optional non-route attributes. The two non-route condom-preference values ("No condoms," "Condoms optional") stay as optional tags and never become a headline; only "Condoms always" can graduate to the headline, because it is the only condom value that is also a blue route. This discloses nothing new: the headline only ever states the route the person already made public to earn blue that way. The gray state reads "No status shared right now": neutral, never a verdict, never "expired/overdue/needs a test." (The labels still avoid "protected/safe/clean/cleared"; "up to date" remains an internal concept word only.)

Why active-protection-required (and why it's not a serostatus gate). Sex with someone who only has a ≤90-day negative test carries a different, higher risk profile than with someone actively protected (a test is a backward-looking snapshot with a window-period blind spot; PrEP/U=U protect continuously). Requiring active protection makes blue mean something. It does not gate on serostatus because the positive state is reachable by the cheapest, most universal route (public condom commitment) and the app carries PrEP/condom/testing access ramps (§12). The efficacy difference between condoms and PrEP/U=U is handled by education, not ranking (a core commitment, not a disclaimer).

Named costs (for outside review, not solved in code). Detectable-poz-in-care can't reach blue until suppressed, and a near-universal positive state can make non-qualifiers more conspicuous; someone who declines PrEP for autonomy must use the condom route or be gray (PrEP-normative tilt). No snapshot outs anyone (wide gray bucket); a "tests yet never blue" pattern is the residual over time.

Clinical model (resolved: the current-state-not-history rule). "Clear" means no current active non-HIV STI. The general rule for any "what about condition X": the badge reflects current state, not diagnosis history, across three never-conflated axes. Chronic/lifelong manageable diagnoses (HSV, HPV) → education, never the badge: they never gray anyone, never appear as a label or attribute; a lifelong gray for a common manageable condition is the permanent-sentence trap the project refuses. A transient active higher-transmission episode (e.g. an HSV outbreak) → pause (§2): gray for the duration, identical to every other gray, un-paused when it clears. Testing recency → the 90-day clock only: an outbreak is a symptom, not a test result, and is never folded into the testing window. Untreated bacterial STIs gray until treated; detectable HIV until undetectable; prior-treated syphilis (serofast) keeps "clear," a reinfection (rising titer) breaks it. HPV (out entirely: education/resources only) causes warts and a slow vaccine/screening-managed cancer risk, not sores; do not conflate it with HSV.

2. Pause

How "not ready" is handled without a warning state.

3. Protection labels & flat attributes

4. Groups

5. Identity & aliases (the canonical unit)

A user has one real account, anchored by a local key (passkey/passphrase) that is never shown and never in a URL. There is no user-facing main handle. Everything shared is an alias.

Each alias has: an opaque id (the only thing server-side), a display handle/avatar (inside the encrypted payload, obfuscating the account), a privacy mode (private/public), and validity/revocation (independent of other aliases).

Server-blind linkage: the account→aliases mapping lives only in the user's encrypted store. The server holds opaque_id → ciphertext, no handle, no avatar, no grouping, no public/private flag.

Opaque-id aliases are the default. A vanity/custom handle is an explicit public opt-in, taught at the choice point: "this makes you findable and is not unlinkable from anywhere else you use this name, and it points at your status, so use it only where you'd be fine being recognized."

No real names, anywhere. The display identity is a handle/alias + avatar, never a first/last/legal name. There is no name field in the system: not stored, not optional, not hidden. A name is a collection surface the product has no use for, so the field doesn't exist. The avatar + handle is the identity on every surface (cards, wallet passes, circles, shares).

6. Public vs private = key distribution

The server's job is identical in both modes (store + serve opaque ciphertext). Only who holds the key differs.

Honesty calibration: "we can't read it" is strong for private aliases, weaker for public (anyone with the link can decrypt). A public link leaks only that alias to the user's chosen audience, categorically safer than a readable server that would leak everyone's data to one coercible party.

7. Resolution & existence-hiding

A public link (handle + /u/ directory) keeps the status gated even though the handle is findable. Anyone who visits sti.care/u/{handle} can knock (request access) and the owner decides. Private links carry the AES key in the URL fragment and give immediate access; they have no knock step. Knock is the public link's gate: findable but not immediately readable.

8. Time-bound & revocable sharing

9. Linking

10. Partner notification

Draft → lock → delivery (mechanics invisible after lock). The user controls the facts (who they linked with) during an editable draft window; then the batch locks and the rest is not the user's concern: it's about the recipients' health.

  1. Draft window (~30 min; one config constant): after the user commits a positive result + recipient list, the batch sits in draft. The user edits freely (add, correct, remove) and may delete the entire report at any time. Each save replaces the prior draft (keep only the latest; discard superseded drafts as they arrive: last-write-wins, no stack).
  2. Lock (at window's end): the last draft standing becomes historical and immutable. Editing the result later, removing a contact later, or un-linking does not touch a locked batch.
  3. Post-lock, invisible by design: the app tells the user nothing about notification timing, delivery, or recipients: no "sending in X," no delivery status, no counts, ever. The locked batch enters the next server-side send cycle, where cross-user batching + timing provide anonymity. This is deliberately removed from the user's concern, and it is its own leak-reduction (no delivery readout that could become a count/timing signal).

The user's real safety valve is deletion: anyone who feels exposed deletes the whole report during draft. So removal is frictionless on purpose, and anything left in is genuinely opt-in.

Two timing jobs, kept separate (this replaces the old single "jitter"): the draft window is the user-facing edit grace (deterministic, "you have ~30 minutes"); the send cycle after lock is the server-side anonymity timing (mixing this person's pings with everyone else's). Don't conflate them; nothing about the send cycle is surfaced to the user.

Content: anonymous, contentless: "a recent contact suggests getting tested." Never who/when/what/how-many; never labeled 1:1-vs-circle (one large anonymity set). A "get screened" nudge, not a real-time PEP alarm; PEP's 72h urgency lives in always-on education independent of when the nudge lands; every notification routes to immediate testing + PEP info.

Server triggers, client composes (HARD RULE). The server's only job in delivery is to wake the recipient, the contentless "there's something for you" ping. All conditional rendering happens on the recipient's own device against locally-held status: which message variant shows, whether the PEP card appears or is suppressed, etc. The server never learns which variant rendered, so no status-correlated tell is created on an anonymous surface. Corollary: any surface without trustworthy local status (the anonymous pull "go get tested" page, opened via an opaque link with no app state) shows PEP/testing info unconditionally: it has nothing to gate on and gating would require a lookup that leaks.

Reachability (MVP scope):

11. Testing reminders

12. Resources (US-only)

Four first-class "find near you" ramps, framed as tools, not verdicts:

PEP urgency is CONTEXTUAL, not a standing label (tone rule). PEP is only time-critical if a person has had a possible HIV exposure and is not otherwise protected. So the urgency framing ("72 hours, the sooner the better, go now") belongs in the post-exposure context (the on-device-composed PEP card that appears after a possible exposure, and the always-on education). In the standing resource finder, "Find PEP near you" must be a NEUTRAL finder label like its siblings, not a permanent "time-critical, go now" alarm. A permanent urgency label is alarming and usually false for whoever is browsing, and it violates the project's normalize-don't-alarm tone. The standing subtitle states when PEP applies ("after a possible HIV exposure") rather than shouting urgency at everyone.

12b. Education layer: the product implies behaviors; education has to teach them

The product repeatedly implies expected actions without spelling them out, and several harm- reduction decisions only hold if education does the teaching. Education is not a static "Learn" tab to bury; it is load-bearing infrastructure that the rest of the model leans on (efficacy nuance, the clinical model, PEP urgency, and what blue does and doesn't mean are all discharged here rather than through labels or ranking). Treat it as a first-class surface.

What it must carry, and when it should surface (contextual, not just a library):

Tone rule for all of the above: supportive, non-shaming, non-clinical-policing; it informs so the person decides, consistent with the rest of the product. (U=U-style explainer copy already in the build is the model for this voice.)

12c. The stranger explainer: what a logged-out first-timer sees

The highest-traffic education surface is the resolved card a logged-out stranger lands on from a shared link (a Grindr/Sniffies profile, a DM). They have no account, no context, and will mostly not tap anything. If they misread the badge, the signal is worse than useless: blue misread as "no condom needed," gray misread as "this person is dirty." This explainer exists to prevent both misreads, at a low reading level, calmly. It lives on the resolution page itself, no account required, not buried in an in-app Learn tab they'll never open.

On the card (always visible): the label + one plain sentence. For blue:

Every rendered card (blue OR gray) has the IDENTICAL "What does this mean?" affordance, same placement, same target, opening the same explainer. A gray card never gets a different or lesser explainer than a blue card: that asymmetry would itself be a viewer-distinguishable tell AND would re-stigmatize gray. (Gray-nothing = the private/unauthorized cold view is not a card (no handle, badge, or affordance, indistinguishable from nonexistent), so there is nothing to explain and no inconsistency; see §7. The explainer question only arises for a rendered card.)

The tap-through explainer covers, in plain words, in this order:

Tone/legibility rules: lead with what things are, never with risk/warning; no red, no warning icons, no "caution" language; everyday words ("tested" not "screened," "prevent HIV" not "biomedical prophylaxis"); short sentences; second person. Gray is explained with the same calm and equal weight as blue. Do not promise more granularity than the privacy model allows: a stranger sees "On HIV prevention" but can never tell PrEP from undetectable (§3 invariant), so the copy says "the tags show what they share," never "the tags tell you how they protect."

12a. Wallet passes, QR & shareable card

The shareable artifacts (Apple Wallet pass, Google Wallet pass, standalone shareable card image) all inherit every badge rule: two-state only, handle + avatar (never a name), sti.care logo, boolean precision (no dates/freshness/streak), no stamp, no count.

Format is a user choice, gated by privacy mode:

Fail closed to gray. Blue is valid only on a fresh, confirmed read. If the pass can't refresh, the server is unreachable, or the last sync is older than the freshness window (24h, one config constant), the pass shows gray, never stale-blue (stale-blue would assert "up to date" the app can't confirm; the worst false positive). The 24h window is a liveness guard, not the 90-day clinical window: it only governs whether a Live pass can still vouch for its blue. Staleness is not a distinct visible state and there is no owner-facing "couldn't refresh" message: stale simply renders as ordinary gray (the owner opens the app to check).

Accessibility: render the alias URL as text beside the QR (screen-readers, manual entry), but only on QR-carrier / public passes; never as a private-status assertion on a pass face. The QR/URL encode an alias (opaque, or a public vanity handle), never a cross-linking account id.

Public-profile use (e.g. a status on a Grindr/Sniffies profile): share a resolving link/QR, not a baked-in status image. The primary shareable for an on-profile context is the link/QR that resolves live on tap, so there is no status snapshot to go stale in someone's profile or chat history (a screenshot of a QR is just the same working QR). A status image may be offered but is explicitly framed as a snapshot ("scan for current"); it is never the default. To advertise where strangers will open it (a dating profile, a bio) without exposing the status itself, the ask-first /u/ handle is the surface: strangers can find you and ask, seeing the status only once you grant it. A private keyed link opens straight to the status, so it is for people you hand it to. Either way the badge is a live-resolving link, never scraped or frozen into an image.

Wallet presentation (framing). The wallet is not a three-way "pick a pass type" menu. It is one pass concept whose face simply reflects what the alias surfaces: a private (or any) alias surfaces a QR-carrier face (no status, link only); a public alias can additionally surface a Live face (status on the face, auto-updating, fail-closed to gray at 24h). So the only real variable is "does status appear on the face," and that is gated by privacy mode (Live requires public). Present it as "here is what this pass shows," not as a menu of formats.

Fast-follow (Scope: Post-MVP, NOT built in the wallet pass): a "quick link" launch into the linkup handshake. The wallet (and a documented home/lock-screen widget recipe) can offer a one-gesture launch into the in-person linkup handshake (§9 / its own pass), because that flow's whole value is lock-screen-speed, and opening the app is the friction it exists to remove. Crucial boundary: the wallet/widget only launches the handshake; the mutual-gesture-is-consent rule still gates the actual link+log in-app (never a passive lock-screen trigger, the pocket-tap risk). This is sequenced AFTER the handshake is proven on its own: build the in-app handshake first, then add the wallet/widget entry as a fast-follow. The widget recipe is a documented production pattern (an iOS Shortcut/widget, Android equivalent), not prototype code.

On device (inside a passphrase/passkey/biometric-derived encrypted blob; server stores only the ciphertext, for cross-device sync): diagnoses, test/treatment dates, badge + clearance math, the full contact graph (per-link opaque notify-tokens, link dates, group membership), alias definitions, visibility preferences.

On the server (cleartext but blind): opaque_alias_id → ciphertext; hash(notify_token) → opaque_handle for routing; push endpoints; the user's unreadable encrypted blob; a batched outbound send-cycle queue (cross-user timing for anonymity). The server triggers delivery (a contentless wake) but never composes content or learns which on-device variant rendered; all conditional rendering is client-side against local status.

Keys: derived locally via Argon2id (passphrase) or passkey/WebAuthn-PRF/biometric; never transmitted. A user-held recovery passphrase is REQUIRED (see below), never a server-side "reset" implying we hold the key.

Pairwise links are exchanged device-to-device (QR/NFC/private link); the server never sees the pairing, and a group is a client-side bundle of pairwise links; the server never learns a group exists or who's in it.

The server CAN learn: a handle/endpoint exists; that some tokens got pinged; ciphertext sizes. It CANNOT learn: the social graph, group membership, any diagnosis, any test/treatment date, or how many contacts anyone has. Caveat (honest): with naive targeted push, the server would observe which handles receive an exposure ping (a recipient set). Closing this requires the broadcast-wake + uniform-poll mitigation (§10); until that's built, "who got notified" is not fully blind, and the docs should not claim otherwise.

Decorrelation & side channels: behavioral unlinkability between sibling aliases (no shared session/IP/push fingerprint); notify-token rotation; uniform timing/shape for resolution and delivery; silent/generic push ("New update") so providers don't learn "exposure."

14. Onboarding & scope


Open items