Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 13 additions & 14 deletions docs/push-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ membership amendments, rate changes, etc.).
|-------|-------|
| Trigger | `INSERT` on `public.group_events` (`on_group_event_push` trigger) |
| Recipient | Active group members with push enabled, excluding the actor |
| Title | `{group name}` (or `"FairShare"` if the group has no name) |
| Title | `{group name}` (or `"Union"` if the group has no name) |
| Body | `NEW.summary` from the event row |
| URL | `/?group={groupUuid}` |
| Source | [sql/fairshare-schema.sql](../sql/fairshare-schema.sql) 1720–1739 |
Expand Down Expand Up @@ -100,19 +100,18 @@ When someone sponsors you into a group via the `offer_group_membership` RPC.

## 4. Handshake completed — new contact

Sent to the QR/meet issuer when another user completes the handshake. Only
active if [sql/handshake-push-schema.sql](../sql/handshake-push-schema.sql) has
been applied; that file replaces the earlier `complete_meet` definition with one
that pushes.
Sent to the QR/meet issuer when another user completes the handshake. The
current `complete_meet(p_token, p_meet_source)` records how the pair met
(`met_via`) and pushes the issuer.

| Field | Value |
|-------|-------|
| Trigger | RPC `complete_meet(p_token, p_meet_source)` after pairing (`p_meet_source` defaults to `URL`; in-app QR scans use `F2F`) |
| Recipient | The meet issuer (`v_meet_request.user_id`) |
| Title | `"FairShare"` |
| Body | `"{name} just joined FairShare via your handshake."` if the newcomer signed up via that token, otherwise `"{name} is now your contact."` |
| Title | `"Union"` |
| Body | `"{name} just joined Union via your handshake."` if the newcomer signed up via that token, otherwise `"{name} is now your contact."` |
| URL | `/?action=view_contact&contact={newContactUuid}` |
| Source | [sql/handshake-push-schema.sql](../sql/handshake-push-schema.sql) 47–145; optional `p_meet_source` and `contacts.met_via` in [sql/contact-intro-and-met-via-migration.sql](../sql/contact-intro-and-met-via-migration.sql) |
| Source | `complete_meet(p_token, p_meet_source)` — current definition in [sql/meet-token-reserve-at-signup.sql](../sql/meet-token-reserve-at-signup.sql); `p_meet_source` / `contacts.met_via` introduced in [sql/contact-intro-and-met-via-migration.sql](../sql/contact-intro-and-met-via-migration.sql) |

Also writes a `contact_notifications` row of type `new_contact` for in-app
display.
Expand All @@ -127,7 +126,7 @@ When a contact saves a selfie of the two of you via `add_contact_selfie`.
|-------|-------|
| Trigger | RPC `add_contact_selfie(...)` after mirrored selfie rows are inserted |
| Recipient | The other party (`p_contact_id`) |
| Title | `"FairShare"` |
| Title | `"Union"` |
| Body | `"{name} took a new selfie with you."` |
| URL | `/?action=view_contact&contact={callerUuid}` |
| Source | [sql/contact-list-schema.sql](../sql/contact-list-schema.sql) 147–155 |
Expand All @@ -144,7 +143,7 @@ Tap opens the intro dialog (`handleNotificationNavigation` in
|-------|-------|
| Trigger | RPC `send_contact_intro(p_contact_a, p_contact_b, p_message)` |
| Recipient | Each of the two parties (`p_contact_a`, `p_contact_b`), excluding the actor |
| Title | `"FairShare"` |
| Title | `"Union"` |
| Body | `"{introducer} wants you to meet {other}"` — `other` is the opposite party for that recipient |
| URL | `/?action=contact_intro&intro={introUuid}` |
| Source | [sql/contact-intro-and-met-via-migration.sql](../sql/contact-intro-and-met-via-migration.sql) `send_contact_intro` |
Expand All @@ -161,7 +160,7 @@ Fanned out to every user who has the actor in their `contacts` list.
|-------|-------|
| Trigger | RPC `notify_contacts_of_profile_picture_change(p_actor_id)`; called from [js/modals.js](../js/modals.js) after a successful upload |
| Recipient | All users whose `contacts` includes `p_actor_id`, excluding the actor |
| Title | `"FairShare"` |
| Title | `"Union"` |
| Body | `"{name} updated their profile picture"` |
| URL | `/?action=view_contact&contact={actorUuid}` |
| Source | [sql/fairshare-schema.sql](../sql/fairshare-schema.sql) 1864–1904 |
Expand All @@ -179,7 +178,7 @@ When the user saves a new display name in preferences (`savePreferences` in
|-------|-------|
| Trigger | RPC `notify_contacts_of_display_name_change(p_actor_id, p_old_display_name, p_new_display_name)`; called from [js/modals.js](../js/modals.js) after a successful profile update when the trimmed name changed |
| Recipient | All users whose `contacts` includes `p_actor_id`, excluding the actor |
| Title | `"FairShare"` |
| Title | `"Union"` |
| Body | `"{old} changed their name to {new}"` — empty/whitespace-only old or new side falls back to `"Someone"` |
| URL | `/?action=view_contact&contact={actorUuid}` |
| Source | [sql/fairshare-schema.sql](../sql/fairshare-schema.sql) 1948–1998; constraint + one-shot deploy [sql/display-name-change-push-schema.sql](../sql/display-name-change-push-schema.sql) |
Expand All @@ -197,7 +196,7 @@ the sponsor share dialog), every user who lists them as a contact is notified.
|-------|-------|
| Trigger | RPC `notify_contacts_of_profile_update(p_actor_id, p_message)`; called from [js/modals.js](../js/modals.js) (`persistProfileEmailPhone`, `savePreferences`) |
| Recipient | All users whose `contacts` includes `p_actor_id`, excluding the actor |
| Title | `"FairShare"` |
| Title | `"Union"` |
| Body | `p_message` (e.g. `"{name} updated their email."`) |
| URL | `/?action=view_contact&contact={actorUuid}` |
| Source | [sql/fairshare-schema.sql](../sql/fairshare-schema.sql) 1909–1945 |
Expand Down Expand Up @@ -247,7 +246,7 @@ When a contact records the date you two met via `set_first_met_date`.
|-------|-------|
| Trigger | RPC `set_first_met_date(p_contact_id, p_met_date)` when `p_met_date` is non-null |
| Recipient | The contact (`p_contact_id`) |
| Title | `"FairShare"` |
| Title | `"Union"` |
| Body | `"{name} says you met on {formatted date}"` |
| URL | `/` (no deep link wired — could route to the contact view) |
| Source | [sql/fairshare-schema.sql](../sql/fairshare-schema.sql) 1952–2002 |
Expand Down
144 changes: 144 additions & 0 deletions docs/transitive-trust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Transitive Trust: Propagation, Discovery, and Group Coherence

**Status: proposal.** Nothing in this document is implemented. It sketches a single
mathematical foundation for four of the Future Directions listed in
[web-of-trust.md](web-of-trust.md) — Discovery, Transitive trust, Group reputation,
and Decay — built entirely from conventions already in the codebase.

## What we have today

Two building blocks already exist in `get_contact_trust_summary`
([contact-details-schema.sql](../sql/contact-details-schema.sql)):

- **Time decay.** Every vouch contributes `2^(-age / 2 years)` — a vouch from two
years ago is worth half of one made today.
- **A fixed two-hop signal.** The Trusted component sums vouches sent to a contact
by mutuals the caller has personally given an "I trust you" vouch.

The Trusted component is the seed of transitive trust, but it has three limits: it
stops at exactly two hops; it treats every trusted mutual identically, no matter how
much the caller actually trusts them; and it is an unbounded sum, so quantity of
vouches can outweigh quality of the people making them.

## The idea: trust attenuates with distance the way it fades with age

Extend the halving rule from time to the graph. A vouch that reaches you through one
extra hop of trust is worth half of one made by someone you trust directly:

```
contribution of a vouch = 2^(-age / T_half) x 2^(-hops / D_half)

T_half = 2 years (existing time half-life)
D_half = 1 hop (proposed distance half-life, tunable)
```

The **transitive trust** the caller holds for a person `x` is the sum, over every
attester `a` who ever vouched for `x`, of `a`'s time-decayed vouches weighted by the
caller's (attenuated) trust in `a` — computed along 'trust' vouch chains out to a
bounded depth (3 hops). Depth 1 is exactly today's Trusted component; this is its
natural continuation, not a replacement.

Formally this is a truncated diffusion on the vouch graph — the same family of
computation as random-walk trust (personalized PageRank) and heat-kernel
propagation — but at Union's scale it is implementable as one bounded recursive SQL
query inside a `SECURITY DEFINER` function. No new infrastructure.

## Why this exact shape

### 1. Sybils cannot manufacture trust

The load-bearing property: **trust flowing into any cluster of accounts is bounded
by the vouch weight crossing the boundary into that cluster.** A thousand fake
accounts vouching for each other in a ring gain nothing from any real user's
perspective unless real people vouch across the boundary — and each real vouch
admits at most a fixed, decaying amount of flow. Trust becomes something you can
only obtain from humans, never mint.

This matters beyond contact screens. If FairShare currency is ever gated or priced
by trust, the trust quantity must be one that cannot be inflated from inside a
cluster of colluding accounts. Diffusion with per-hop attenuation has exactly this
cut-bound property; raw counts (today's sums) do not.

### 2. It degrades gracefully

Attenuation is exponential, so distant vouches contribute vanishingly little and
truncating at depth 3 discards at most a `2^-3` fraction of the signal. No cliffs,
no surprises when the graph grows.

### 3. It stays explainable in one sentence

The app already teaches users one rule: *worth half after two years.* This adds the
same rule in a second dimension: *worth half per extra hop.* The Trust Details
dialog explanation grows by one clause, not a paragraph.

## What it unlocks

| Future direction (web-of-trust.md) | With diffusion |
|---|---|
| Transitive trust | The score itself: "trusted by people you trust," to depth 3, weighted by how much you trust the intermediaries. |
| Discovery | Rank people you haven't met by transitive trust from your position in the graph. Show the score — never the path. |
| Group reputation | The Candidates screen shows each candidate's transitive trust from existing members, giving endorsers a principled signal beyond the sponsor's message. |
| Decay | Already inside the kernel — one constant per dimension, both user-legible. |

## Group Coherence: a related, separate number

Groups admit a complementary metric. Build the graph of vouches *among a group's
active members* and compute its **algebraic connectivity** (the second-smallest
eigenvalue of the graph Laplacian, the Fiedler value). This is the standard measure
of whether a network is one community or two cliques joined by a thread:

- **High** when vouching spans the whole membership — the group moves as one flock.
- **Low** when the group is quietly fragmenting into sub-cliques, *before* it is
visible in chat activity or membership churn.

Normalized 0–100, this is a "Group Coherence" stat on the group page. It is cheap —
for groups of realistic size it computes in milliseconds by power iteration, nightly
and cached. Later it could inform constitution mechanics (e.g. admission thresholds
that keep coherence above a floor), but the observable stat alone is valuable.

## Privacy model

These are constraints, not features. Every mechanism above must preserve the
guarantees in [web-of-trust.md](web-of-trust.md):

1. **No path enumeration, ever.** Every RPC returns a single aggregate number.
Nothing reveals *via whom* trust flows — a "trust path" display is explicitly
rejected here, because any path rendering exposes private vouch and contact
edges of third parties.
2. **No new readable rows.** The `attestations` table keeps no SELECT policy; all
computation stays inside `SECURITY DEFINER` functions.
3. **Bucketed display.** Transitive scores follow the heart-dialog rules: fully
hidden below a threshold, floored to buckets above it, so a single new vouch
cannot be detected by watching a number move.
4. **Recipients never learn attesters.** Transitive trust is always the *caller's*
view of a *third party*; the reverse direction (who feeds *my* score) is never
queryable, matching `get_contact_history`'s one-direction rule.

## Implementation sketch

Three phases, each independently shippable:

1. **Deepen the Trusted component.** Inside `get_contact_trust_summary`, replace
the fixed two-hop Trusted query with a recursive CTE over 'trust' attestations,
applying `2^(-d / D_half)` per hop, depth capped at 3, fan-out capped per node.
Reuses the existing `contacts.trust_score` cache. New constants sit next to
`c_decay_a`: `c_hop_half`, `c_max_depth`.
2. **Discovery / group reputation RPC.** A function returning bucketed transitive
trust for the sponsorship and endorsement flows (candidate screens first — the
moment members most need a signal about someone they haven't met).
3. **`get_group_coherence(group_id)`.** Laplacian power iteration in plpgsql or an
edge function, computed nightly per group and cached.

## Open questions

- **Constants.** `D_half`, depth cap, and bucket sizes should be picked by
simulating on production-shaped graphs, not by intuition.
- **Low-degree inference.** For users with very few contacts, even bucketed
aggregates can leak who vouched. The "fully hidden below N" rule likely needs a
larger N for transitive scores than for direct counts.
- **Which vouch types propagate?** Proposal: only 'trust' carries flow across hops;
other types ('love', 'respect', 'help') contribute at depth 1 only, since their
meanings are not transitive.
- **Cost.** A depth-3 recursive query over `attestations` is fine at current scale
but wants the same caching pattern `trust_score` already uses; worth measuring
before Phase 2.
8 changes: 4 additions & 4 deletions sql/contact-intro-and-met-via-migration.sql
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ BEGIN
v_push_msg := COALESCE(v_caller_name, 'Someone')
|| CASE
WHEN v_is_new_account
THEN ' just joined FairShare via your handshake.'
THEN ' just joined Union via your handshake.'
ELSE ' is now your contact.'
END;

Expand All @@ -176,7 +176,7 @@ BEGIN
PERFORM public.send_push_to_users(
ARRAY[v_meet_request.user_id],
v_caller_id,
'FairShare',
'Union',
v_push_msg,
'/?action=view_contact&contact=' || v_caller_id::text
);
Expand Down Expand Up @@ -468,15 +468,15 @@ BEGIN
PERFORM public.send_push_to_users(
ARRAY[p_contact_a],
v_uid,
'FairShare',
'Union',
v_body_a,
v_url
);

PERFORM public.send_push_to_users(
ARRAY[p_contact_b],
v_uid,
'FairShare',
'Union',
v_body_b,
v_url
);
Expand Down
2 changes: 1 addition & 1 deletion sql/contact-list-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ BEGIN
-- Deep-link the push so tapping the OS notification opens the caller's
-- contact details screen (handled in push.js handleNotificationNavigation).
PERFORM public.send_push_to_users(
ARRAY[p_contact_id], v_caller_id, 'FairShare', v_msg,
ARRAY[p_contact_id], v_caller_id, 'Union', v_msg,
'/?action=view_contact&contact=' || v_caller_id::text
);
END;
Expand Down
4 changes: 2 additions & 2 deletions sql/display-name-change-push-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
-- lists this user as a contact.
--
-- Run in Supabase SQL Editor after migrations that define the current
-- `contact_notifications_notification_type_check` (e.g. handshake-push-schema.sql).
-- `contact_notifications_notification_type_check` (e.g. contact-intro-and-met-via-migration.sql).

ALTER TABLE public.contact_notifications
DROP CONSTRAINT IF EXISTS contact_notifications_notification_type_check;
Expand Down Expand Up @@ -69,7 +69,7 @@ BEGIN
PERFORM public.send_push_to_users(
v_contact_ids,
p_actor_id,
'FairShare',
'Union',
v_msg,
'/?action=view_contact&contact=' || p_actor_id::text
);
Expand Down
12 changes: 6 additions & 6 deletions sql/fairshare-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2367,7 +2367,7 @@ begin
perform public.send_push_to_group(
NEW.group_id,
NEW.actor_id,
coalesce(v_group_name, 'FairShare'),
coalesce(v_group_name, 'Union'),
NEW.summary,
'/?group=' || NEW.group_id::text
);
Expand Down Expand Up @@ -2404,7 +2404,7 @@ begin
perform public.send_push_to_group(
NEW.group_id,
NEW.user_id,
coalesce(v_group_name, 'FairShare') || ' Chat',
coalesce(v_group_name, 'Union') || ' Chat',
v_body,
'/?group=' || NEW.group_id::text || '&tab=chat'
);
Expand Down Expand Up @@ -2546,7 +2546,7 @@ begin
-- tap on the OS notification deep-links straight to that contact's details
-- screen (via handleNotificationNavigation).
perform public.send_push_to_users(
v_contact_ids, p_actor_id, 'FairShare', v_msg,
v_contact_ids, p_actor_id, 'Union', v_msg,
'/?action=view_contact&contact=' || p_actor_id::text
);
end;
Expand Down Expand Up @@ -2586,7 +2586,7 @@ begin
end loop;

perform public.send_push_to_users(
v_contact_ids, p_actor_id, 'FairShare', p_message,
v_contact_ids, p_actor_id, 'Union', p_message,
'/?action=view_contact&contact=' || p_actor_id::text
);
end;
Expand Down Expand Up @@ -2640,7 +2640,7 @@ begin
end loop;

perform public.send_push_to_users(
v_contact_ids, p_actor_id, 'FairShare', v_msg,
v_contact_ids, p_actor_id, 'Union', v_msg,
'/?action=view_contact&contact=' || p_actor_id::text
);
end;
Expand Down Expand Up @@ -2701,7 +2701,7 @@ begin
values (p_contact_id, v_caller_id, 'met_date_set', v_msg, jsonb_build_object('met_date', p_met_date));

-- Send Web Push.
perform public.send_push_to_users(ARRAY[p_contact_id], v_caller_id, 'FairShare', v_msg);
perform public.send_push_to_users(ARRAY[p_contact_id], v_caller_id, 'Union', v_msg);
end;
$$ language plpgsql security definer;

Expand Down
Loading