# endorse-peer.screen Single-screen breakdown for the **AE7 endorsement composer** — the scope-claimed positive-reputation surface a `colleague`-state peer uses to offer an endorsement on a named competency (≤ 80 char) with optional evidence (≤ 200 char). Endorsements never aggregate to a global score; they are scope-claimed, recency-decayed, and accept-gated by the endorsee per AE7. Sibling to [approval-card.screen](./approval-card.screen.md) (compose+approve pattern). Voice register: **working** by default; **plain** on block / banned-phrase / abuse-heuristic states (per voice §V2c — sanction copy is plain). ## Layout (iPhone 17 logical 393×852, drafting — default) ``` ┌─────────────────────────────────────────────────┐ │ ◄ status bar (system) │ 47pt ├─────────────────────────────────────────────────┤ │ ◄ Profile Endorse q-berlin [⋯] │ 56pt — top bar ├─────────────────────────────────────────────────┤ │ q-berlin · colleague │ header: endorsee handle + relationship badge │ Berlin · DE · operates: tryst, content-of │ surface line (read-only from peer-profile) ├─────────────────────────────────────────────────┤ │ │ │ ─── Scope you're endorsing ─── │ │ ╭─────────────────────────────────────────╮ │ │ │ Mandarin-speaking subject handling │ │ scope-claim textarea (≤ 80 chars) │ │ │ │ │ ╰─────────────────────────────────────────╯ │ │ 44 / 80 │ live count (right-aligned) │ │ │ Suggested from q-berlin's surfaces: │ chip row (single-tap fills textarea) │ [bookings-tryst listing] [Mandarin handling] │ │ [tour pricing · Berlin] [PPV calendaring] │ │ │ │ ─── Evidence (optional) ─── │ │ ╭─────────────────────────────────────────╮ │ │ │ Helped me draft three Mandarin replies │ │ evidence textarea (≤ 200 chars) │ │ that landed cleanly. Took 20 minutes. │ │ │ │ │ │ │ ╰─────────────────────────────────────────╯ │ │ 72 / 200 │ │ │ │ Endorsements show on q-berlin's profile │ helper text (working) │ once they accept. Withdraw any time. │ │ │ ├─────────────────────────────────────────────────┤ │ [ Cancel ] [ Offer endorsement ] │ 56pt — action bar (primary = filled) └─────────────────────────────────────────────────┘ │ 34pt — home indicator ``` ## Components | Component | Brief ref | Notes | |---|---|---| | Top bar | A §navigation | Back to `peer-profile.screen.md`; `[⋯]` = overflow (view existing endorsements you've offered this peer, report this peer). | | Header | AE7, AE2 | Endorsee handle + `colleague` badge (right of handle, tappable to peer-profile). Sub-line: region + surface-operated chips from endorsee's directory row (per AE5). | | Scope-claim textarea | AE7 | Free-text, ≤ 80 chars. Live count right-aligned. Placeholder rotates per state. On blur: §V6 banned-phrase check + K3 PII gate. Empty = "drafting" state. | | Suggested chips | AE5, AE7 | Up to 4 chips derived from endorsee's `peer_profiles.surfaces_operated` + their public endorsements' scope-claims. Single-tap = replaces textarea content (with confirm if textarea has user-typed content). | | Evidence textarea | AE7 | Optional, ≤ 200 chars. Same §V6 + K3 checks on blur. Helper hint: "What did they do? Be concrete." | | Action bar | AE7, [approval-card §action-bar](./approval-card.screen.md) | `[Cancel]` (left, secondary) + `[Offer endorsement to {handle}]` (right, primary filled). Primary button label dynamically includes the endorsee handle. Disabled in `empty` / `banned-phrase-flagged` / `over-cap` states. | ## States 1. **empty** — textareas blank. Primary button disabled with label "Offer endorsement to q-berlin". Placeholder in scope-claim textarea: "Name a specific competency. ≤ 80 chars." 2. **drafting** (default) — user is typing; live count updates; primary button enabled once scope-claim is ≥ 3 chars and not banned-phrase-flagged. No errors shown. 3. **banned-phrase-flagged** — on blur, scope-claim or evidence tripped §V6 banned-phrase list (per [voice §V6](./00-system-voice.md)). Plain-register inline error directly under the offending textarea: "This phrase isn't supported here: {phrase}. Try another wording." Primary button disabled until the phrase is removed. No mention of any model or "filter" — plain noun only. 4. **over-cap** — endorser has hit the per-coop endorsement cap (per AE-Q5 lean: per-coop cap + recency decay + mod review). Plain-register banner above the action bar: "You've offered the most endorsements this period for {coop name}. Try again after {date}, or withdraw an older one." Primary button disabled. `[Manage your offered endorsements →]` affordance. 5. **K3-gate-blocked** — on send-tap, K3 PII gate detected restricted content (govt name per K3c-1, precise location per K3f-2) in either textarea. Plain-register interrupt sheet: "Held this back — contains restricted content. Edit and try again. See audit for the row." Sheet dismisses to the composer with the offending textarea focused; primary button re-enabled after edit. Counter-action row written to `agent_actions` per AE constraints. 6. **sent (offered)** — primary button tapped, K3 + §V6 passed, row written. Card animates off (right-slide), success haptic, transitions back to `peer-profile.screen.md` with a working-register receipt: "Endorsement offered to q-berlin. They'll accept or pass." Sticky 30s window with `↶ withdraw` affordance. 7. **accepted-pending notification** — endorsee accepted; this state is the *post-state* read from `peer-profile.screen.md` or the notifications surface, surfaced back to the endorser as a working-register receipt: "q-berlin accepted your endorsement for *Mandarin-speaking subject handling*. It's public on their profile." Not a composer state; documented here for completeness. 8. **withdrawn (long-press)** — endorser long-pressed a previously-offered endorsement (from `peer-profile.screen.md` or the overflow's "endorsements you've offered" list) and chose `Withdraw`. Working-register confirm sheet: "Withdraw your endorsement for *{scope}*? They keep the receipt; the public surface drops." On confirm, `peer_endorsements.withdrawn_at` set; no notification sent to endorsee. 9. **colleague-state-broken** — endorsee withdrew colleague-state between this composer's open and send-tap (per AE2 `disconnected` / `blocked`). Plain-register sheet replaces the composer: "q-berlin is no longer a colleague. Endorsements need a colleague connection. Reconnect first." `[Back]` only; send-path closed. 10. **duplicate-existing** — endorser already has a public (accepted) endorsement on this scope for this endorsee (scope-claim text-match, case-insensitive trim per AE7). Plain-register banner above scope-claim: "You already endorse q-berlin for *Mandarin-speaking subject handling*. Edit the existing one instead." Primary button label swaps to `[Edit existing endorsement]`, route to the same composer pre-filled with the prior row's content + an `Edit mode` chip in the header. 11. **abuse-heuristic spike** — endorsement-trading-ring heuristic (per AE-Q5) detected a spike between this endorser and endorsee (e.g. reciprocal endorsements within 24h, dense local subgraph). Plain-register banner above the action bar: "This endorsement is held for moderator review. You'll hear back within 48 hours." Primary button label swaps to `[Submit for review]`; on tap, row writes with `accepted_at=NULL` + a mod-review flag, no public surfacing until cleared. The endorsee is *not* notified during the hold. 12. **VoiceOver / reduced motion / Dynamic Type XXL** — inherits chat-home accessibility patterns (per [Brief X](./X-accessibility.brief.md)). Textareas read with character-count hint; suggested chips read as "suggested scope: {text}, double-tap to use"; action bar reads scope-claim + endorsee handle before reading button label. ## Interactions / gestures - **Tap a suggested chip** → fills scope-claim textarea (replaces existing content with confirm if non-empty). - **Tap scope-claim or evidence textarea** → opens keyboard; live counter starts updating. On blur: §V6 + K3 gate fires. - **Tap `[Offer endorsement to {handle}]`** → primary action. Runs final K3 gate, writes `peer_endorsements` row with `accepted_at=NULL, public=false`, animates off-right, returns to `peer-profile.screen.md`. - **Tap `[Cancel]`** → if textareas dirty, confirm-discard sheet ("Drop this endorsement draft?"); else dismiss. - **Long-press a previously-offered endorsement** (in overflow's offered-list or on `peer-profile.screen.md`) → action sheet: `Withdraw` / `Edit` / `View` (only `Withdraw` for accepted-public ones; `Edit` only if not yet accepted). - **Swipe-down on the composer** → confirm-discard sheet (if dirty) or dismiss. - **Tap colleague badge in header** → opens `peer-profile.screen.md` (without losing draft — composer state persists for 60s on return). ## In-the-wild copy - (working, primary button) "Offer endorsement to q-berlin" - (working, helper) "Endorsements show on q-berlin's profile once they accept. Withdraw any time." - (working, sent receipt) "Endorsement offered to q-berlin. They'll accept or pass." - (working, accepted receipt) "q-berlin accepted your endorsement for *Mandarin-speaking subject handling*. It's public on their profile." - (working, withdraw confirm) "Withdraw your endorsement for *{scope}*? They keep the receipt; the public surface drops." - (plain, §V6 banned-phrase) "This phrase isn't supported here: {phrase}. Try another wording." - (plain, K3 leak) "Held this back — contains restricted content. Edit and try again. See audit for the row." - (plain, over-cap) "You've offered the most endorsements this period for Berlin escort coop. Try again after April 28, or withdraw an older one." - (plain, colleague broken) "q-berlin is no longer a colleague. Endorsements need a colleague connection. Reconnect first." - (plain, duplicate) "You already endorse q-berlin for *Mandarin-speaking subject handling*. Edit the existing one instead." - (plain, abuse hold) "This endorsement is held for moderator review. You'll hear back within 48 hours." ## Edge cases - **Endorsee withdraws colleague-state mid-compose** — composer flips to state 9 on the next foreground (subscription tick on `peer_connections`). Any pending K3 / §V6 checks are dropped; the row is never written. - **Duplicate existing on same scope** — state 10. Edit-mode reuses the same composer, pre-filled, with primary button label `[Save endorsement]`. Editing does not re-trigger accept-gate; the endorsee's prior accept persists unless the scope-claim text materially changes (per AE-Q5 recency decay logic — small edits don't re-start the clock). - **Endorsement-trading-ring heuristic spike** — state 11. The hold is silent to both parties beyond the endorser's banner; the endorsee learns nothing until mod-review clears or rejects. Cleared → row flips to `offered` and notifies the endorsee normally. Rejected → row stays `withdrawn` and the endorser gets a plain-register notice via [notifications](./notification-rich-preview.screen.md): "Your endorsement of q-berlin was not accepted by moderators. Reason category: {category}." - **§V6 banned phrase in scope-claim only** — state 3 fires under the scope-claim textarea, not the evidence one. Evidence textarea remains editable. - **K3 leak in evidence only** — state 5; sheet dismisses with the evidence textarea focused, not scope-claim. - **Endorser is in `incognito` posture (AE11)** — endorsement can be offered but won't surface on endorsee's profile until endorser flips to `discoverable` or `open`. Working-register helper text appears above the action bar: "You're incognito. q-berlin will see your endorsement, but it won't show on your profile to others until you change posture." - **Endorsee blocks endorser between offer and accept** — pending endorsement is silently cancelled (per AE2 block semantics); row marked `withdrawn_at = now()`, no notification to endorser beyond a plain-register receipt on next foreground: "Your endorsement of q-berlin can't be delivered." No mention of block status (per AE10 block opacity). - **Endorsement against self** — DB CHECK constraint `endorser_id <> endorsee_id` rejects; composer never opens (`Endorse {handle}` action is gated upstream in `peer-profile.screen.md`). - **Cross-locale endorser + endorsee** — scope-claim is stored canonical-EN + per-locale rendered per AD6 triad. Suggested chips show in endorser's locale; what the endorsee sees on accept is in endorsee's locale; no "translated from…" annotation ever (per AD opacity). - **Reduced motion / VoiceOver / Dynamic Type XXL** — see state 12. - **`@model-boss /translate` degraded** — per [Brief M §M2a](./M-error-degraded-modes.brief.md): scope-claim composer remains usable; AD3 register-faithful re-authoring of the endorsee-facing view queues; helper text gains a degraded chip "ai-copilot is catching up — q-berlin will see this once it's caught up." No data loss; row writes with `accepted_at=NULL` as usual. ## Related - [Brief AE §AE7](./AE-provider-social-network.brief.md) — parent design (scope-claim shape, accept-gate, recency decay, mod-review). - [Brief AE §AE2](./AE-provider-social-network.brief.md) — colleague-state requirement. - [Brief AE §AE5](./AE-provider-social-network.brief.md) — endorsee directory profile (suggested-chip source + accept-surfacing target). - [Brief AE §AE10](./AE-provider-social-network.brief.md) — abuse-heuristic + mod-review path for state 11. - [Brief AE §AE11](./AE-provider-social-network.brief.md) — posture affecting public surfacing. - [Brief K §K3](./K-safety-blocklist.brief.md) — PII gate firing in state 5. - [Brief AD](./AD-multilingual-opaque.brief.md) — scope-claim storage triad + endorsee-facing render. - [Brief I](./I-audit-trust-replay.brief.md) — every offer / accept / withdraw / mod-hold is an `agent_actions` row. - [Brief X](./X-accessibility.brief.md) — VoiceOver / reduced-motion / Dynamic Type behavior. - [voice §V6](./00-system-voice.md) — banned-phrase check fired in state 3. - [approval-card.screen.md](./approval-card.screen.md) — compose+approve sibling pattern. - [notification-rich-preview.screen.md](./notification-rich-preview.screen.md) — accept / mod-reject delivery surface. ## Out of scope - The endorsee-side accept surface (lives in `peer-profile.screen.md` + notifications; not authored here). - Endorsement-trading-ring heuristic tuning (per AE-Q5; defer to P5+ with data volume). - Public profile rendering of accepted endorsements (lives in `peer-profile.screen.md`). - Cross-org endorsements (Quinn-as-Demimonde) — personal-only at P0 per AE-Q6 + Brief W §W4. - Cross-platform variants (iPad / web; deferred per [Brief E](./E-cross-platform.brief.md)). ## Open questions - **END-Q1** Suggested chips source ranking — surface endorsee's `surfaces_operated` first, or surface their three most-public-recently-accepted scope-claims first? `[exploratory]` (lean: surfaces first when endorsee has 0 public endorsements, recent-public first once they have ≥ 3 — the chip surface should evolve with the endorsee's reputation graph). - **END-Q2** Edit-after-accept semantics — when the endorser edits an already-accepted endorsement (state 10 path with material text change), does the endorsement revert to `accepted_at=NULL` and require re-accept, or stay public with a "edited {date}" badge? `[blocking]` (lean: revert to `accepted_at=NULL` for material text changes (Levenshtein ≥ 0.3 on scope-claim), stay public with edit-badge for evidence-only edits; treats scope-claim as the load-bearing field). - **END-Q3** Mod-review hold notification to endorsee — is the endorsee told that an endorsement is being reviewed (transparency), or kept fully opaque until the review clears (anti-coordination)? `[blocking]` (lean: opaque; the endorsee sees the endorsement only after mod-review clears, preventing the trading-ring pair from coordinating around the hold).