implemented and contract-backed (web) — validation-gated (G4 + [F39 security audit](/specs/f39-security-audit)); not flipped to ✅ in the registry until a real counterparty loop is observed

Spec: F71 — Inter-company confirmation links (Loop 2)

Status: implemented and contract-backed (web) — validation-gated (G4 + F39 security audit); not flipped to ✅ in the registry until a real counterparty loop is observed

The Contract Graph owns each authenticated and public confirmation operation individually. This is an inventory ownership update only; G4 counterparty, browser, and production evidence remain open.

This is the moat’s growth engine. competitive-strategy-v2.md §10.4 names the company→company acquisition loop (Loop 2) as “not yet designed; this is the dangerous gap.” field-tests.md §14 is blunt: “Loop 2 exists only if the product workflow naturally requires an external party to interact with Lumi” — a referral link, a social post, a testimonial, and an affiliate discount are explicitly not Loop 2. This spec designs the one mechanism that is: a no-login confirmation link the owner sends to a client, supplier, or subcontractor, whose completion both updates the owner’s brief and puts a future Lumi buyer in contact with the product.

Validation-gated. Draft design tied to field-test gate G4 (Loop 2 exists). Not marked shipped until at least one owner sends an external confirmation link and the loop is observed with real counterparties.

Contract Graph migration note (2026-07-14)

F71 is now declared in contracts/features/F71.cue. The contract owns the authenticated link lifecycle, token-only public summary boundary, response evidence, safe states, rate/anti-enumeration behavior, and Loop-2 event shape. The F39 security audit is already passing; the remaining product claim is G4 real-counterparty validation plus target browser runtime.

1. Problem & CEO Job

Trust the signal + grow without sales. The strategy’s no-sales GTM (strategy v2 §10.1) cannot survive on the owner→staff loop alone — that grows one account from 1 to ~8–20 seats and then stops (strategy §2 Bet B). New companies must arrive without paid ads. The only durable, non-marketing way that happens is if Lumi’s real workflow forces an external party to touch Lumi.

A fit-out contractor’s work is full of forced external sign-offs: a client approves a quote / design / handover, a supplier confirms a price / delivery date / stock, a subcontractor confirms a task / deadline / completion photo. Today those confirmations live in Zalo (“anh xác nhận giúp em”) and evaporate — no record, no status, endless chasing, and the classic “em tưởng… / anh chưa xác nhận…” dispute. If the confirmation instead flows through a no-login Lumi link, the owner gets a recorded yes/no/date with evidence, and the external party — often an SME operator with the same Zalo+Excel chaos — experiences Lumi and can start their own workspace.

Without this, acquisition is content + referral only, which the strategy classifies as marketing work, not a flywheel (field-tests §2.3). With it, the product itself distributes.

2. User Stories & Acceptance Criteria

  • As an owner, I want to send a no-login confirmation link for one specific item (a commitment / task / deal / approval) to a client, supplier, or subcontractor, so that I get a recorded answer without chasing in Zalo.

    • Given a brief item or commitment, when the owner taps send confirmation and labels the external party + role, then Lumi mints an unguessable single-item link the owner can paste into Zalo.
    • Given a created link, when the owner views the item, then its pending external confirmation is visible and surfaced on the brief/horizon (F12/F23).
    • Given a link is created in dev or production, when its public URL is returned, then its host belongs to that same environment; a dev token is never sent to the production frontend/API.
  • As an external party with no account, I want to confirm / decline / add a note, date, or photo on a simple page, so that I don’t have to sign up.

    • Given a valid token, when the external party opens the link, then they see only the owner-shared summary of that one item — never any other org data — and confirm/decline controls.
    • Given they submit a response, when it saves, then the owner’s item status updates and an EvidenceSource of kind external_confirmation is attached, citing what the party answered.
    • Given an expired / revoked / already-answered token, when opened, then a friendly state shows and no org data leaks.
  • As an owner, I want the response reflected as trustworthy status, so that I stop chasing.

    • Given a confirmed link, when the brief next renders, then the item shows confirmed by [party] on [date] with the response as evidence (not the owner’s assertion).
  • As Lumi (growth), I want to instrument the loop, so that G4 is measurable and curious counterparties can convert.

    • Given an external party completes a confirmation, when the success page renders, then it shows tasteful Lumi attribution and a soft “tạo brief cho công ty của bạn” CTA (no spam, no dark patterns).
    • Given external interactions, when they occur, then view / complete / curiosity-click / convert events are recorded for the Loop-2 (G4) metric — never exposing the owner’s data to analytics.

3. Scope Boundaries

  • In: a tokenized public confirmation link per item; a public no-auth confirm page (mobile-first, Vietnamese-first, localizable for the counterparty); response captured as EvidenceSource + item status update; expiry, revocation, and single-use semantics; rate-limiting + anti-enumeration on the public endpoints; Loop-2 acquisition instrumentation + one soft self-serve CTA.
  • Out:
    • A full external client portal (multi-item, login, history) — the strategy explicitly warns against this scope creep (strategy §10.4); this is one link, one item.
    • External parties editing internal records, seeing other items, or browsing the org.
    • Payments / e-signature / legal contracting.
    • Internal multi-step approvals — that is F26. F71 is the external leg; an F26 approval may spawn an F71 link for an outside sign-off.
    • Referral codes / affiliate discounts (F59) — those are marketing, not Loop 2; do not conflate.

4. Data Model

Two new models; reuses EvidenceSource for the captured response.

  • New — ConfirmationRequest (UUIDModel, organization FK — required):
    • created_by → OrganizationMember
    • subject_type + subject_id (generic link to the confirmed object: Commitment / Task / Deal / ApprovalRequest) — or explicit nullable FKs; decide in §12.
    • token — cryptographically random, unguessable, URL-safe, single-item scope
    • external_party_label (free text the owner enters, e.g. “Khách A”, “NCC gỗ”) and external_party_role ∈ {client, supplier, subcontractor, other}
    • shared_summary — the only fields the owner chose to expose to the outside (snapshotted, so later edits to the internal item never leak)
    • status ∈ {pending → viewed → (confirmed | declined)} | expired | revoked
    • response_note, response_date, response_upload (FK → Upload, optional photo), responded_at
    • expires_at
    • Status transitions update backend/DIAGRAMS.md state machine + GLOSSARY.
  • New — ConfirmationEvent (UUIDModel, FK ConfirmationRequest): kind ∈ {viewed, completed, curiosity_click, converted}, timestamp, coarse source info — for the G4 metric. Stores no owner business data; safe for growth analytics.
  • Reused: a confirmed/declined response creates an EvidenceSource (source_type=external_confirmation) on the subject item, so the brief can cite the external party’s own words.

5. API Contract

MethodPathAuth/RoleRequestResponseErrors
POST/api/confirmations/Owner/Admin of owning org{subject_type, subject_id, party_label, party_role, shared_fields[], expires_in?}201 {id, token, public_url}403 role; 404 cross-org subject
GET/api/confirmations/Member of owning orgfilter by subject/status200 [{...}]—
POST/api/confirmations/{id}/revoke/Owner/Admin—200 {status:"revoked"}403; 404 cross-org
GET/api/public/confirmations/{token}/No auth—200 {shared_summary, party_label, status}404 invalid; 410 expired/revoked
POST/api/public/confirmations/{token}/respond/No auth, rate-limited{decision, note?, date?, upload_id?}200 {status}409 already answered; 410 expired; 429 rate
POST/api/public/confirmations/{token}/interest/No auth, rate-limited{kind:"curiosity_click"}204429 rate
  • Tenant scoping (CRITICAL — this is the highest-risk surface in Lumi). Authenticated endpoints filter by organization_id=self.request.organization_id (Rule 1). The public endpoints are scoped by token only and must return nothing beyond shared_summary — the owner-selected, snapshotted fields for that one item. No org name leakage beyond what the owner shares, no other items, no enumeration. As F02 §1 puts it: one cross-tenant leak ends the product. This spec adds a new public attack surface and must be reviewed by the F39 security audit before shipping.
  • Token: ≥128 bits entropy, single-item, expirable (default e.g. 14 days), revocable; lookups are constant-time / not enumerable; failed lookups are indistinguishable (no “exists vs expired” oracle beyond the deliberate 404/410 split, which carries no org data).
  • AI: n/a for the confirmation itself. The owner’s suggested message to the counterparty may be AI-drafted (F09/F12) with evidence; the confirmation record is human, not AI.

6. UI / UX

  • Internal: a Send confirmation action on brief items (F25), commitments (F09), deals (F27/F50), and tasks — with a small sheet to set party label, role, and which fields to share. Pending/answered status shows inline and on the brief/horizon.
  • Public route: /c/{token} — PublicConfirmationPage, deliberately outside the authed app shell. Shows the one shared item, confirm/decline, optional note/date/photo, then a success state with tasteful Lumi attribution + soft CTA.
  • States to design: valid-pending, viewed, confirmed, declined, expired, revoked, already-responded, error, rate-limited.
  • shadcn components: Card, Button, Textarea, Calendar (date), file upload (F34 path), Badge. No raw Tailwind for UI (Rule 4).
  • i18n namespace: confirmations — keys in both en/ and vi/ (Rule 3), Vietnamese-first. The public page must localize to the counterparty’s language where known. Copy is owner/trade vocabulary: “Xác nhận giúp em”, “Đồng ý / Cần sửa”, “Ngày giao”, not “Approve record”.
  • Mobile-first + trust: the public page is the first impression a future customer gets of Lumi — it must be calm, fast, clearly the owner’s request (not spam), and obviously safe to tap. Follow DESIGN.md + TASTE.md.

7. Test Plan

#CaseTypeWhere
1Owner creates a link for a commitment; token returnedbackendbackend/tests/test_confirmations.py::TestConfirmationRequestCreate
2Cross-tenant: public token exposes ONLY shared_summary, never other org data (mandatory)backendbackend/tests/test_multitenancy.py
3Public respond → status updates + EvidenceSource attached to subjectbackendbackend/tests/test_api.py
4Expired / revoked / already-answered token → safe state, no leakbackendbackend/tests/test_api.py
5Only Owner/Admin can create or revoke a linkbackendbackend/tests/test_api.py
6Rate-limit + anti-enumeration on public endpointsbackendbackend/tests/test_api.py / test_rate_limiting.py
7Loop-2 events (viewed/completed/curiosity_click) recorded without org databackendbackend/tests/test_api.py
8E2E: owner sends link → external confirms (no login) → owner brief updatese2efrontend/tests/e2e/lumi.spec.ts describe("Confirmation links / Loop 2")

Backend coverage ≥99%; new E2E describe block required. The public, no-auth surface demands the multitenancy test (#2) and the security audit sign-off as hard gates.

8. Current Behavior

The web implementation ships an authenticated owner/admin confirmation lifecycle and a public, no-login /c/{token} experience. The token snapshots only the owner-selected summary; public view, response, expiry/revocation, rate-limit, evidence, and curiosity events are implemented and covered by the F39 security audit. Backend and worker release workflows explicitly bind FRONTEND_URL and CONFIRMATION_PUBLIC_BASE_URL to their matching dev or production frontend, so generated links cannot cross databases through the wrong host. The remaining truth is validation, not missing code: G4 has not yet observed a real owner-to-counterparty loop, and browser runtime evidence is not claimed by this contract slice.

9. Error States

  • Invalid token → public 404 with a neutral “link không hợp lệ” page; no hint about org existence.
  • Expired / revoked → 410 + friendly confirmations.public_expired page; owner can re-issue.
  • Already answered → 409 + read-only summary of the prior answer; idempotent, no double-write.
  • Rate-limited (F20) → 429; public endpoints have their own stricter budget than authed ones because they are an abuse target.
  • Photo upload failure → degrade gracefully; the text confirmation still saves.
  • Never leak org/tenant data in any error path; error copy carries zero business content.

10. Diagrams & Docs Touched

  • frontend/DIAGRAMS.md: add the public /c/{token} route (outside the authed shell) + auth-state diagram showing the no-login branch.
  • backend/DIAGRAMS.md: ConfirmationRequest state machine; request lifecycle for the public, token-scoped endpoints.
  • GLOSSARY.md: add Loop 2, confirmation link, external confirmation.
  • F39 security audit: new public attack surface — must be audited.
  • DIAGRAMS.md: show Loop 2 as the company→company acquisition path.
  • ADR: public no-auth endpoint pattern + tenant-safe field exposure (see §12).

11. Implementation Audit (2026-06-15)

Built on web (backend + frontend), tests green; validation-gated, not yet shipped.

  • Backend: apps/confirmations — ConfirmationRequest + ConfirmationEvent models, owner/admin-gated authed ViewSet (/api/confirmations/), and the public no-auth, token-scoped endpoints (/api/public/confirmations/<token>/ view/respond/interest). The public path returns only the snapshotted shared_summary (never a live read of the internal item). ext_confirmation EvidenceSource kind added; per-scope throttling (public_confirm, public_confirm_view); response upload_id scoped to the owning org. Token default is a per-row callable (unguessable). backend/tests/test_confirmations.py — 30 tests covering all of §7; apps/confirmations at 100% coverage, full suite 99.57%.
  • Frontend: public /c/:token PublicConfirmationPage outside the auth shell using a bare axios client that sends no X-Organization-ID; internal SendConfirmationDialog wired into Commitments; confirmations i18n namespace (en + vi). Unit tests (src/test/F71Confirmation.test.tsx) + e2e Confirmation links / Loop 2 (F71) block.
  • F39 security audit: PASS (2026-06-15). Full finding-by-finding audit of the public surface at docs/security/F71-confirmation-links.md (score 10/10; no CRITICAL/HIGH/MEDIUM). Hardening applied during the audit: respond is now row-locked (select_for_update in a transaction) against concurrent-answer races.
  • Still required before ✅: field-test gate G4 observed with a real counterparty (the only remaining gate). Branches: feat/F71-be, feat/F71-fe.

Original audit (2026-06-14)

Not built — and named by strategy v2 as the single most valuable missing piece. Constraints:

  • Security-critical. Public, unauthenticated endpoints that touch tenant data are the riskiest thing in the product. The snapshot-shared_summary design (not a live read of the internal item) is the core safety mechanism — implement it that way, audit it (F39), and write the cross-tenant test first.
  • Must be a real workflow leg, not an afterthought. field-tests §21 explicitly warns: “do not build external link as afterthought if Loop 2 is strategic.” It is strategic. Build it into the brief-item action flow (F25), not as a bolt-on.
  • Acceptance is the G4 gate — a real owner sends a real link to a real counterparty and the loop is observed — not a passing test suite alone.
  • Reuses EvidenceSource and Upload; the new surface is two models + the public page + the internal send action.

12. Open Questions / Decisions

  • Generic FK vs explicit nullable FKs for the confirmed subject (Commitment/Task/Deal/Approval) — pick the pattern that keeps tenant-scoping simplest and the public serializer narrowest.
  • ADR required: the public no-auth endpoint pattern + the tenant-safe shared_summary snapshot is a structural security decision — write the ADR before implementation.
  • Attribution dose on the public page — enough that a curious counterparty learns it’s Lumi (drives G4), not so much it reads as the owner spamming their client. Tune with the validation cohort.
  • Anti-enumeration / abuse — exact rate-limit budgets, token TTL defaults, and bot handling.
  • Expiry default and whether owners can extend/re-issue without minting a new token.

13. Bug / Change Log

Type: Bug Fix / Deployment integrity

Summary:

  • Dev backend and worker releases now set both public frontend base variables to https://dev.runlumi.app; production releases set them explicitly to https://runlumi.app.
  • The manual worker environment example and dev bootstrap guide carry the same host mapping, so a recovery deployment cannot silently restore the production fallback in dev.
  • This prevents a token stored in the dev database from being opened by the production frontend, which calls the production API and can only report that the otherwise valid token is unknown.
  • A repository policy test covers backend and worker release workflows for both environments.

Tests:

  • scripts/tests/test_confirmation_public_base_workflow.py

2026-07-02 — Stale test-class reference corrected (§7 row 1)

Type: Documentation Fix

Summary:

  • §7 row 1 previously referenced backend/tests/test_api.py::TestConfirmationRequest. After #943 split test_api.py into domain modules, confirmation tests live in backend/tests/test_confirmations.py. The class name also changed: the create-link tests are in TestConfirmationRequestCreate. Updated file path and class name (verified by grep).

2026-07-02 — Spec drift corrected: all 8 §2 ACs checked

Type: Documentation Change

Summary:

  • All 8 [ ] acceptance criteria in §2 changed to [x]. §11 (2026-06-15 audit) confirms the full backend (apps/confirmations, 100% coverage, 30 tests covering all §7 rows) and frontend (PublicConfirmationPage, SendConfirmationDialog, en+vi i18n, unit tests, e2e block) are implemented and green. F39 security audit passed 2026-06-15 (score 10/10). The only remaining gate before registry ✅ is the field-test G4 observation with a real counterparty — not a missing implementation gap.

2026-06-16 — Deal-quote provenance hook (F50 U4 glue)

Type: Feature

  • The public respond path (apps/confirmations/views.py _apply_response) gained an additive, guarded side-effect: when the confirmed/declined subject is a deal, it logs a DealActivity (action="note_added", origin="confirmation_link", metadata {decision, external_party, external_party_role, note}) on that deal so the Loop-2 answer appears in the deal timeline as coming from the confirmation link (F50 U11 provenance). Fires only when obj.deal_id is set — commitment/task/approval subjects are unaffected. Owner-facing F71 flow is otherwise unchanged. The deal báo giá itself (build_deal_quote_summary) is built in apps/crm and passed by the frontend as shared_summary on create — the F71 create serializer was not modified. Tests: backend/tests/test_confirmations.py::TestDealConfirmationProvenance.

2026-06-15 — Web implementation (backend + frontend) with tests

Type: Feature

Summary:

  • Implemented F71 end-to-end on web: new apps/confirmations backend (two models, authed owner ViewSet, public no-auth token-scoped endpoints, ext_confirmation EvidenceSource, throttling, migrations) and frontend (/c/:token public page with a no-org-header client, SendConfirmationDialog on Commitments, en+vi i18n).

Reason:

  • Closes the strategy’s “dangerous gap” (Loop 2) with a working, tested mechanism.

Impact:

  • Public, no-auth surface now exists. Backend apps/confirmations at 100% coverage, full backend suite 2629 passed / 99.57%. Frontend unit tests + e2e block added; F71 type-checks clean. Registry stays 🔲 — flips to ✅ only after the F39 security audit sign-off and field-test gate G4 with a real counterparty (per §11).

Fixes made during implementation:

  • Token default changed from a once-evaluated secrets.token_urlsafe(32) (every row would share one token) to a per-row callable.
  • create() now returns {id, token, public_url} (was echoing the input serializer).
  • Response upload_id lookup scoped to the owning org (tenant safety).
  • Frontend used unsupported asChild; switched to buttonVariants / DialogClose render=.

Tests:

  • backend/tests/test_confirmations.py (30 tests, all §7 rows); frontend src/test/F71Confirmation.test.tsx + e2e Confirmation links / Loop 2 (F71).

2026-06-14 — Spec created (Loop 2 designed — closing strategy’s “dangerous gap”)

Type: Feature

Summary:

  • Created F71 to design the company→company acquisition loop (Loop 2): no-login external confirmation links to clients/suppliers/subcontractors. Previously absent from every spec.

Reason:

  • competitive-strategy-v2.md §10.4 names Loop 2 “not yet designed; this is the dangerous gap,” and field-tests §14 makes it the decisive acquisition signal (G4). The no-sales GTM fails without it. Scope change authorized by the session goal (read strategy → upgrade specs to serve the mission).

Impact:

  • New draft feature F71 in the registry at 🔲 Not started. Adds a public, no-auth surface — the highest-risk tenancy boundary in the product; gated on the F39 security audit and a mandatory cross-tenant test.

Tests:

  • Test plan defined (§7); not yet implemented.

Notes:

  • Do not build as an afterthought; wire into the brief-item action flow (F25). Do not mark shipped until field-test G4 is observed with a real counterparty. Do not conflate with referral/affiliate (F59) — that is marketing, not Loop 2.