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
EvidenceSourceof kindexternal_confirmationis 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,organizationFK — required):created_by→OrganizationMembersubject_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 scopeexternal_party_label(free text the owner enters, e.g. “Khách A”, “NCC gỗ”) andexternal_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|revokedresponse_note,response_date,response_upload(FK →Upload, optional photo),responded_atexpires_at- Status transitions update backend/DIAGRAMS.md state machine + GLOSSARY.
- New —
ConfirmationEvent(UUIDModel, FKConfirmationRequest):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/declinedresponse creates anEvidenceSource(source_type=external_confirmation) on the subject item, so the brief can cite the external party’s own words.
5. API Contract
| Method | Path | Auth/Role | Request | Response | Errors |
|---|---|---|---|---|---|
| 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 org | filter by subject/status | 200 [{...}] | — |
| 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"} | 204 | 429 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 beyondshared_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 bothen/andvi/(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
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Owner creates a link for a commitment; token returned | backend | backend/tests/test_confirmations.py::TestConfirmationRequestCreate |
| 2 | Cross-tenant: public token exposes ONLY shared_summary, never other org data (mandatory) | backend | backend/tests/test_multitenancy.py |
| 3 | Public respond → status updates + EvidenceSource attached to subject | backend | backend/tests/test_api.py |
| 4 | Expired / revoked / already-answered token → safe state, no leak | backend | backend/tests/test_api.py |
| 5 | Only Owner/Admin can create or revoke a link | backend | backend/tests/test_api.py |
| 6 | Rate-limit + anti-enumeration on public endpoints | backend | backend/tests/test_api.py / test_rate_limiting.py |
| 7 | Loop-2 events (viewed/completed/curiosity_click) recorded without org data | backend | backend/tests/test_api.py |
| 8 | E2E: owner sends link → external confirms (no login) → owner brief updates | e2e | frontend/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+ friendlyconfirmations.public_expiredpage; 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:
ConfirmationRequeststate 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+ConfirmationEventmodels, 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 snapshottedshared_summary(never a live read of the internal item).ext_confirmationEvidenceSource 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/confirmationsat 100% coverage, full suite 99.57%. - Frontend: public
/c/:tokenPublicConfirmationPageoutside the auth shell using a bare axios client that sends noX-Organization-ID; internalSendConfirmationDialogwired into Commitments;confirmationsi18n namespace (en + vi). Unit tests (src/test/F71Confirmation.test.tsx) + e2eConfirmation 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_updatein 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_summarydesign (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
EvidenceSourceandUpload; 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_summarysnapshot 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
2026-07-25 — Dev confirmation links stay on the dev frontend
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 tohttps://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 splittest_api.pyinto domain modules, confirmation tests live inbackend/tests/test_confirmations.py. The class name also changed: the create-link tests are inTestConfirmationRequestCreate. 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 aDealActivity(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 whenobj.deal_idis 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 inapps/crmand passed by the frontend asshared_summaryon 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/confirmationsbackend (two models, authed owner ViewSet, public no-auth token-scoped endpoints,ext_confirmationEvidenceSource, throttling, migrations) and frontend (/c/:tokenpublic page with a no-org-header client,SendConfirmationDialogon 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/confirmationsat 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_idlookup scoped to the owning org (tenant safety). - Frontend used unsupported
asChild; switched tobuttonVariants/DialogClose render=.
Tests:
backend/tests/test_confirmations.py(30 tests, all §7 rows); frontendsrc/test/F71Confirmation.test.tsx+ e2eConfirmation 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.