From 41f551991664b1ca2e3b4fd15a5d6b923b8c924b Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 14 May 2026 12:51:19 -0400 Subject: [PATCH 01/42] docs(legal): add baseline legal documents (privacy, ToS, DPA, subprocessors, cookies) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated by the resolutionflow-legal skill from a code scan of the FastAPI backend + React frontend on commit 0564646. Each document is a starting point for attorney review, not legal advice. Includes: - privacy-policy.md, terms-of-service.md, cookie-policy.md (public-facing) - dpa.md (contractual; signed with MSP customers) - subprocessor-list.md (Railway, Anthropic, Voyage, Stripe, Resend, Sentry, PostHog, Google Fonts — confirmed live as of scan) - data-inventory.md + classification.md (Phase 1/2 working files) - attorney-review-checklist.md (consolidated [LEGAL REVIEW] punch list) - implementation-verification.md (claim-by-claim audit vs. actual code) Three blocking issues filed before public publication: - #175 deletion-on-offboarding (or rewrite retention claims) - #176 narrow Sentry send_default_pii + Session Replay config - #177 EU/UK consent for PostHog + Google Fonts Public-facing documents intentionally route physical-mail requests through support@ rather than publishing the LLC's registered address. Co-Authored-By: Claude Opus 4.7 (1M context) --- legal/attorney-review-checklist.md | 154 ++++++++++++ legal/classification.md | 87 +++++++ legal/cookie-policy.md | 104 +++++++++ legal/data-inventory.md | 289 +++++++++++++++++++++++ legal/dpa.md | 334 +++++++++++++++++++++++++++ legal/implementation-verification.md | 119 ++++++++++ legal/privacy-policy.md | 215 +++++++++++++++++ legal/subprocessor-list.md | 82 +++++++ legal/terms-of-service.md | 287 +++++++++++++++++++++++ 9 files changed, 1671 insertions(+) create mode 100644 legal/attorney-review-checklist.md create mode 100644 legal/classification.md create mode 100644 legal/cookie-policy.md create mode 100644 legal/data-inventory.md create mode 100644 legal/dpa.md create mode 100644 legal/implementation-verification.md create mode 100644 legal/privacy-policy.md create mode 100644 legal/subprocessor-list.md create mode 100644 legal/terms-of-service.md diff --git a/legal/attorney-review-checklist.md b/legal/attorney-review-checklist.md new file mode 100644 index 00000000..ea51a815 --- /dev/null +++ b/legal/attorney-review-checklist.md @@ -0,0 +1,154 @@ +# Attorney Review Checklist + +Generated: 2026-05-14 +Documents in scope: +- [privacy-policy.md](privacy-policy.md) +- [terms-of-service.md](terms-of-service.md) +- [dpa.md](dpa.md) +- [subprocessor-list.md](subprocessor-list.md) +- [cookie-policy.md](cookie-policy.md) + +This checklist consolidates every `[LEGAL REVIEW]` tag and every issue surfaced by the scan that needs attorney judgment, with enough context that an attorney can bill efficiently. + +--- + +## A. Highest-priority items (block publication) + +### A1. Implement deletion-on-offboarding OR rewrite retention claims + +**Where:** Privacy Policy §6 (retention table + deletion paragraph); DPA §6.2 (return/deletion). +**Issue:** Today, account "deletion" only soft-deletes the user row and revokes refresh tokens. The account row, audit logs, session content (`ai_sessions`, `sessions`, conversation transcripts, ticket snapshots, escalation packages), uploaded files in Railway Object Storage, AI usage records, sales leads, beta feedback, and notification history are **not** automatically purged. +**Why this matters:** GDPR Art. 5(1)(e) "storage limitation" + DPA §6.2 require ResolutionFlow to delete or anonymize Customer Data after the export window. The current draft claims this happens. The code does not enforce it. +**Two acceptable paths:** +1. **Build the deletion job** (preferred): add a scheduled task that purges all account-scoped Customer Data 30 days after account deletion (or sooner on customer request), and revise the language only if the implementation differs from what's drafted. +2. **Rewrite the language** to describe the actual behavior — "deletion on request, processed within X days" — and commit to an SLA the team can hit manually. + +### A2. Sentry data-protection posture is broader than typical defaults + +**Where:** Privacy Policy §3.2 ("Information we collect automatically" — error/performance monitoring paragraph); DPA Annex B; Subprocessor List Operational table. +**Issue:** +- Backend Sentry SDK is initialized with `send_default_pii=True` ([main.py:18](../backend/app/main.py#L18)) — user IDs and request fragments flow to Sentry by default. +- Frontend Sentry Session Replay runs with `maskAllText: false, blockAllMedia: false` ([instrument.ts:9-12](../frontend/src/instrument.ts#L9-L12)) — replays may contain visible page text and media. +**Why this matters:** Customer Data (ticket bodies, conversation content) can land in Sentry replays and error reports. Disclosing this is one option; the better path is narrowing the config first. +**Recommended:** mask text on routes that render Customer Data; set `send_default_pii=False`; add Sentry data-scrubbing rules for `intake_content`, `conversation_messages`, `ticket_data`, `escalation_package`. Then the existing disclosure narrows naturally. + +### A3. EU/UK consent banner is required before PostHog / Google Fonts can fire + +**Where:** Privacy Policy §4 (legal-basis table), §10 (cookies); Cookie Policy §2.3, §3.1. +**Issue:** PostHog is initialized unconditionally in [main.tsx:17-23](../frontend/src/main.tsx#L17-L23) with `persistence: 'localStorage+cookie'`. Google Fonts loads on every public page. For EU/UK visitors, both require prior consent under ePrivacy Directive Art. 5(3) / UK PECR. +**Action:** implement a consent management mechanism (or geo-gate) before launching public-landing EU traffic, OR confirm the product is geo-blocked from EU/UK. The Cookie Policy already references a consent mechanism — wire it up or remove the reference. + +### A4. Article 27 representative designation + +**Where:** Privacy Policy §2 ("Who we are"), §13 ("Contact us — EU/UK"). +**Issue:** ResolutionFlow LLC has no EU or UK establishment. If EU/UK Data Subjects are reachable, GDPR Art. 27 / UK GDPR Art. 27 require designation of a written representative in the EU and (separately) in the UK. +**Action:** either appoint representatives (commercial services exist for ~$500–$2,000/year per region) and update the contact section, or document a decision not to offer the Services to EU/UK Data Subjects and add a geo-gate. + +### A5. Liability cap, indemnification, dispute resolution + +**Where:** Terms of Service §10 (disclaimers), §11 (limitation of liability), §12 (indemnification), §13 (dispute resolution). +**Issue:** All four sections contain industry-standard defaults but are commercial-risk decisions that depend on revenue, insurance, and counterparty appetite. +**Specifically to calibrate:** +- §11(b): "fees paid in the preceding 12 months" cap is a SaaS default; confirm. +- §11(c) carve-outs: confirm the list (confidentiality, indemnity, DPA breach, gross negligence, willful misconduct, statutory non-limitable) matches insurer expectations. +- §12.2: IP indemnity scope is US patents/copyrights/trademarks; confirm geographic and IP-type scope. +- §13.1: governing law set to Georgia (LLC's state). Counsel may prefer Delaware. +- §13.2: chose Cobb County, Georgia for venue (matches LLC location). Counsel may prefer arbitration (JAMS/AAA) for enterprise neutrality and cost predictability. + +### A6. Address withholding on public docs + +**Where:** Privacy Policy §2; ToS §14.7; DPA §9.4. +**Issue:** User asked that the LLC's registered address (716 Hearthstone Xing, Woodstock, GA 30189 — home address) **not** appear on the website. The Privacy Policy and ToS therefore route physical-mail requests through `support@resolutionflow.com`. This is acceptable for routine inquiries but: +- **CAN-SPAM** requires a physical postal address in every marketing email — flag if marketing emails are sent. +- **Service of legal process** may require disclosure on demand; some states (e.g., DE) require a registered agent address publicly. +**Recommendation:** retain a registered agent (Northwest, ZenBusiness, Harbor Compliance — ~$100-$250/year) and update all three documents to use the registered-agent address. This solves the privacy concern without compromising legal-process service. + +--- + +## B. Important items (calibrate before contracting with enterprise) + +### B1. Sub-processor notice period + +**Where:** DPA §3.4.2. +**Default chosen:** 30 days. +**Note:** Enterprise MSP buyers often demand 60-90 days. Decide what you will accept. + +### B2. Breach notification SLA + +**Where:** DPA §3.7. +**Default chosen:** 72 hours (GDPR baseline). +**Note:** Some enterprise buyers demand 24-48 hours. Verify ResolutionFlow can detect and report within the chosen window. + +### B3. SCC governing law / forum / supervisory authority + +**Where:** DPA Annex D. +**Default chosen:** Ireland (DPC) — most common. +**Note:** Counsel may prefer another EU member state depending on Customer base. + +### B4. Audit rights cost allocation + +**Where:** DPA §3.8.2. +**Default chosen:** Customer bears its own audit costs. +**Note:** Some enterprise buyers will request a free audit or one funded by ResolutionFlow if findings are material. + +### B5. Export window + +**Where:** ToS §9.4; DPA §6.2. +**Default chosen:** 30 days. +**Note:** Confirm the export tooling actually supports a 30-day window. If not, reduce. + +### B6. Refund / proration policy + +**Where:** ToS §5.2. +**Default chosen:** Non-refundable except where required by law. +**Note:** Common alternatives: 14-day satisfaction window; prorated refund on annual plans; no refund on monthly plans. Decide and update. + +### B7. Anthropic and Voyage no-training claims + +**Where:** Privacy Policy §4 (no model training note); Subprocessor List AI section. +**Status as of 2026-05-14:** Anthropic's commercial API tier does not train on customer data by default. Voyage AI's embedding API is similarly transactional. +**Action:** before publication, re-verify each subprocessor's current public terms. Re-verify each time this list is republished. + +--- + +## C. Documentation gaps to fix in the product before claiming + +These are claims in the documents that aren't fully backed by code today. See [implementation-verification.md](implementation-verification.md) for the line-by-line picture. Pick "fix the code" or "rewrite the claim" for each: + +| Claim in documents | Reality today | Recommended path | +|---|---|---| +| Account deletion deletes personal information within a defined window | Soft-delete of user only; account-scoped content retained indefinitely | **Fix the code** (A1) | +| Audit logs retained for a defined period | Retained indefinitely; IP addresses included | **Fix the code** (add 12-month purge) or rewrite to "retained indefinitely for security purposes" | +| Refresh / verification / password-reset tokens are purged after expiry | Rows persist; no cleanup job | Fix the code (add nightly purge of `WHERE expires_at < now() OR revoked_at IS NOT NULL`) | +| File uploads are deleted on account deletion | No lifecycle policy on Railway Object Storage | Fix the code or document the actual retention | +| Sales leads / beta feedback / survey responses purged on schedule | No purge job | Fix the code or document | +| Encryption at rest (broad claim) | Railway encrypts at infra layer; only PSA credentials encrypted at app layer | Already disclosed accurately — verify Railway's attestation and keep the language as drafted | +| Multi-factor authentication | Not implemented for direct logins; SSO available via Google/MS | Acceptable as drafted; consider requiring MFA for admins | +| Microsoft Learn MCP no Customer Data egress | Verified: integration retrieves docs only | Disclosed accurately | + +--- + +## D. Items left out by design (confirm) + +- **Gemini (Google AI):** code path exists, no key in prod — omitted from Subprocessor List. Add when activated, with 30-day notice. +- **Autotask, HaloPSA:** code stubs in `services/psa/` only — not active and not disclosed. Add when activated. +- **OpenAI:** no key/code path detected — omitted. +- **Microsoft Learn MCP:** disclosed as a non-subprocessor (read-only doc lookup, no Customer Data egress). +- **ConnectWise:** correctly classified as customer-authorized data source, not a sub-processor. + +--- + +## E. Sign-off checklist + +Before publishing: + +- [ ] A1 — deletion on offboarding implemented or language adjusted +- [ ] A2 — Sentry config narrowed (or disclosure expanded) +- [ ] A3 — EU/UK consent banner implemented (or geo-gate confirmed) +- [ ] A4 — Art. 27 representatives appointed (or geo-gate confirmed) +- [ ] A5 — liability / indemnity / dispute resolution calibrated with counsel +- [ ] A6 — registered-agent address obtained; addresses updated +- [ ] B1–B6 — commercial decisions confirmed +- [ ] B7 — Anthropic + Voyage AI no-training stance re-verified within 30 days of publication +- [ ] Implementation gaps in §C resolved (build or revise) +- [ ] Effective Date and Version bumped on every material change going forward diff --git a/legal/classification.md b/legal/classification.md new file mode 100644 index 00000000..a7634d9e --- /dev/null +++ b/legal/classification.md @@ -0,0 +1,87 @@ +# Phase 2 — Classification + +Generated: 2026-05-14 +Based on: `data-inventory.md` (Phase 1) and user-confirmed answers to Section 7 questions. + +## Confirmed parameters + +| Parameter | Value | +|---|---| +| Legal entity | **ResolutionFlow LLC** | +| Registered address (DPA only — not public) | 716 Hearthstone Xing, Woodstock, GA 30189 — **`[LEGAL REVIEW: replace with registered-agent address before publishing any contracts that include this]`** | +| Privacy / legal contact | `support@resolutionflow.com` | +| Jurisdictions in scope | US federal + state baseline, CCPA/CPRA, EU GDPR, UK GDPR, all in-force US state comprehensive privacy laws (VA, CO, CT, UT, TX, OR, MT, IN, IA, TN, DE, NH, NJ, MD, MN, RI, KY). Reachable from anywhere the US permits traffic. | +| Live LLM provider | **Anthropic only** (current). Future plans: BYOK + multi-LLM — disclose only Anthropic now; revise on rollout. | +| Live embedding provider | **Voyage AI** (key set) | +| Gemini | Code path present but not currently live — **exclude from public Subprocessor List** until activated. | +| Active PSA provider | **ConnectWise only** (Autotask + HaloPSA stubs not live). | +| Sentry region | US | +| Railway region | US | +| Microsoft Learn MCP | Enabled. Pulls Microsoft docs; no Customer Data egress — disclose as informational only, not a Customer-Data subprocessor. | +| Children's data | None — disclaim under 16 / COPPA. | +| Public surfaces | Marketing pages, sales-lead form, signup, and public flow shares only. | +| Backup retention | 90 days. | +| Third-party tools outside the codebase (Zapier, CRM, etc.) | None at this time. | + +## Controller vs Processor mapping + +| Data category | RF role | Controller | Notes | +|---|---|---|---| +| User accounts (name, email, password_hash, profile) | **Controller** | ResolutionFlow LLC | Covered by Privacy Policy | +| Audit logs (incl. IP addresses) | **Controller** | ResolutionFlow LLC | Privacy Policy; legal basis = legitimate interests (security) | +| Telemetry (PostHog, Sentry, AI usage tracking) | **Controller** | ResolutionFlow LLC | Privacy Policy; legitimate interests + consent for analytics in EU/UK | +| Marketing leads (`sales_leads`, beta signup) | **Controller** | ResolutionFlow LLC | Privacy Policy; legitimate interests / consent | +| Billing / subscription / Stripe IDs | **Controller** | ResolutionFlow LLC | Privacy Policy; contract performance | +| **PSA-derived ticket data, intake_content, conversation_messages, file uploads, escalation packages, resolution notes, embeddings derived from this content** | **Processor** | The MSP customer | DPA-governed. RF acts on documented instructions. | +| Knowledge Flywheel / flow content authored within a tenant | **Processor** | The MSP customer | Tenant-isolated; no cross-tenant sharing detected. | +| Resolution-note writeback to ConnectWise | **Processor** | The MSP customer | RF writes to the customer's own ConnectWise tenant under instruction. | + +## Under CCPA/CPRA + +- ResolutionFlow is a **Business** for: user account data, marketing data, billing, telemetry. +- ResolutionFlow is a **Service Provider** for: all Customer Data routed through the Services (covered by DPA, which serves as the written contract required by CCPA §1798.140(ag)). +- ResolutionFlow **does not sell or share** personal information for cross-context behavioral advertising. + +## Legal-basis assignments (GDPR Art. 6) + +| Purpose | Legal basis | +|---|---| +| Provide the Services to the user / MSP | Contract performance (Art. 6(1)(b)) | +| Authenticate, secure, prevent fraud | Legitimate interests (Art. 6(1)(f)) — balancing test documented | +| Transactional email (invites, password resets, billing) | Contract performance | +| Marketing email | Consent (Art. 6(1)(a)) **`[LEGAL REVIEW: confirm whether RF is sending marketing emails today and obtain consent at the appropriate touchpoint]`** | +| Product analytics (PostHog) and error tracking with PII (Sentry `send_default_pii=True`) | Legitimate interests + consent where required for non-essential cookies (EU/UK) **`[LEGAL REVIEW: a consent banner is required before PostHog/cookie-persisted analytics fire for EU/UK visitors]`** | +| AI / LLM features | Contract performance (it's part of the Services) | +| Aggregated product improvement | Legitimate interests | +| Comply with legal requests | Legal obligation (Art. 6(1)(c)) | + +## International transfer mechanism + +- **EU/UK → US transfers** rely on **Standard Contractual Clauses (Module 2 / Module 3 as applicable) + UK Addendum**. **`[LEGAL REVIEW: consider EU-US Data Privacy Framework certification when ResolutionFlow LLC qualifies — it materially improves the transfer story]`** +- All current subprocessors host in the US. SCCs are the baseline transfer mechanism for each. + +## Sensitive-category posture + +- ResolutionFlow does **not** intentionally collect GDPR Art. 9 special categories or CPRA "sensitive PI." +- **Incidental collection risk:** free-text fields (`intake_content`, `conversation_messages`, `session_feedback`, `outcome_notes`) can incidentally contain anything an MSP technician types — including healthcare details if the MSP serves healthcare clients. This is the basis for the ToS prohibition on PHI / regulated-data submission without a BAA in place. + +## HIPAA / PCI posture + +- **HIPAA:** ResolutionFlow is **not currently HIPAA-compliant**. ToS will prohibit PHI submission absent a BAA. +- **PCI:** SAQ A scope — Stripe Elements handles card data; ResolutionFlow stores only Stripe IDs. + +## Children's data + +- B2B IT-professional tool. Disclaim under 16 / COPPA in Privacy Policy. + +## Open commercial / legal decisions punted to attorney + +Captured for the attorney-review checklist (Phase 4) — not blockers for generation: + +- Governing law + venue / arbitration vs litigation +- Liability cap calibration +- Indemnification scope +- Refund / proration policy +- Article 27 EU representative designation +- Whether to pursue EU-US DPF certification +- Whether to use a registered-agent address for the LLC on public + contractual docs diff --git a/legal/cookie-policy.md b/legal/cookie-policy.md new file mode 100644 index 00000000..b26570a5 --- /dev/null +++ b/legal/cookie-policy.md @@ -0,0 +1,104 @@ +# Cookie Policy + +**Effective Date:** 2026-05-14 +**Version:** 1.0 + +> **DRAFT — not legal advice.** This document was generated from a code scan and is intended for review by a qualified attorney before publication. + +This Cookie Policy explains how ResolutionFlow LLC ("ResolutionFlow," "we," "us," or "our") uses cookies and similar technologies on the ResolutionFlow website and Services. + +## 1. What are cookies and similar technologies? + +Cookies are small text files stored on your device when you visit a website. We also use related technologies, including: + +- **Local storage and session storage** — browser storage similar to cookies but typically larger and not sent on every request +- **Software development kits (SDKs)** — code that collects information from your browser as you use a website + +For simplicity, we use "cookies" to refer to all of these throughout this policy unless we note otherwise. + +## 2. Cookies and storage we use + +We categorize browser storage by purpose. Where applicable laws require consent for non-essential cookies and storage, we will obtain consent before setting them. `[LEGAL REVIEW: a consent banner is required before PostHog and any non-essential analytics fires for EU/UK visitors]` + +### 2.1 Strictly necessary + +These items are essential for the Services to function. They cannot be disabled while you use the Services. + +| Name / pattern | Type | Set by | Purpose | Duration | +|---|---|---|---|---| +| `access_token` | localStorage | ResolutionFlow (first-party) | Holds your short-lived API access token so you stay signed in across pages and reloads | Until logout / token expiry | +| `refresh_token` | localStorage | ResolutionFlow (first-party) | Used to obtain a new access token without re-entering your password | Until logout or session limit (default 14 days absolute, 3 days idle) | + +**Note on storage choice.** We deliberately store these tokens in your browser's `localStorage` rather than in HTTP-only cookies. Tokens in `localStorage` are accessible to JavaScript running on the page, so a cross-site-scripting (XSS) attack against the Services could expose them. We mitigate this risk with content-security headers, short access-token lifetimes, idle and absolute session limits, and the ability to revoke all sessions on password change. + +### 2.2 Functional / preference + +These items are not strictly necessary but disabling them reduces functionality. + +| Name | Type | Set by | Purpose | Duration | +|---|---|---|---|---| +| `theme-storage` | localStorage | ResolutionFlow (first-party) | Remembers your dark / light theme preference | Persistent | +| `rf-editor-fullscreen` | localStorage | ResolutionFlow (first-party) | Remembers whether you prefer fullscreen editor mode | Persistent | +| `rf-intended-plan` | localStorage | ResolutionFlow (first-party) | Carries a pricing-page selection into the signup flow | Cleared after signup | +| `recentFlows` storage key | localStorage | ResolutionFlow (first-party) | Remembers the flows you've recently opened so the navigation MRU works | Persistent | +| "Step feedback hint shown" flag | localStorage | ResolutionFlow (first-party) | Hides a one-time coachmark after you've seen it | Persistent | +| "Rated sessions" list | localStorage | ResolutionFlow (first-party) | Suppresses the post-session rating prompt for sessions you've already rated | Persistent (capped at 100 entries) | +| "Escalation queue seen" set | localStorage | ResolutionFlow (first-party) | Marks notifications you've seen so badges clear correctly | Persistent | + +### 2.3 Analytics + +These items help us understand how the Services are used so we can improve them. They are set only with your consent in jurisdictions that require it. `[LEGAL REVIEW: the consent banner described here is not currently implemented]` + +| Name | Type | Set by | Purpose | Duration | +|---|---|---|---|---| +| `ph_*` (e.g., `ph__posthog`) | Cookie + localStorage | PostHog (third-party) | Identifies your browser to PostHog so we can attribute events to a stable identifier, capture page views, autocapture interactions, and report Web Vitals. The cookie is set because we configure PostHog with `persistence: 'localStorage+cookie'`. | Up to 12 months | + +We also use Sentry to monitor errors and a sampled subset of browser sessions (1% of normal sessions, 100% of sessions in which an error occurs). Sentry does not set tracking cookies but does collect telemetry about your browser interactions during sampled sessions. See the [Privacy Policy](privacy-policy.md) and our [Subprocessor List](subprocessor-list.md). + +### 2.4 Advertising + +We do not use advertising cookies, advertising pixels, or cookies for cross-context behavioral advertising. + +### 2.5 Embedded third-party services + +- **Google Fonts** — Our public website loads fonts from `fonts.googleapis.com` and `fonts.gstatic.com`. Google receives your IP address as part of loading the fonts. Google does not set cookies via these requests, but the IP-address exposure is a disclosure. `[LEGAL REVIEW: consider self-hosting fonts to remove this disclosure]` + +## 3. Your choices + +### 3.1 Managing consent + +Where required by law, we obtain your consent for analytics and other non-essential storage via a consent mechanism on the Services. You can change your preferences at any time. `[LEGAL REVIEW: implement and link to the consent mechanism here]` + +### 3.2 Browser controls + +Most browsers allow you to: + +- Block all cookies +- Block third-party cookies +- Clear cookies when you close the browser +- Receive notification when a cookie is set + +Disabling all cookies and `localStorage` will prevent the Services from functioning correctly because authentication relies on browser storage. + +For browser-specific instructions, see: + +- [Chrome](https://support.google.com/chrome/answer/95647) +- [Firefox](https://support.mozilla.org/en-US/kb/cookies-information-websites-store-on-your-computer) +- [Safari](https://support.apple.com/guide/safari/manage-cookies-sfri11471/mac) +- [Edge](https://support.microsoft.com/en-us/help/4027947/microsoft-edge-delete-cookies) + +### 3.3 Do Not Track signals + +The Services do not currently respond to "Do Not Track" browser signals because there is no industry consensus on how to interpret them. + +### 3.4 Global Privacy Control + +We treat **Global Privacy Control (GPC)** signals as an opt-out of sale or sharing of personal information for California and other states where required by law. We do not sell or share personal information for cross-context behavioral advertising regardless of GPC. + +## 4. Changes to this Cookie Policy + +We may update this Cookie Policy from time to time. Material changes will be announced through the Services and the "Effective Date" above will be updated. + +## 5. Contact + +Questions about our use of cookies? Contact us at **support@resolutionflow.com**. diff --git a/legal/data-inventory.md b/legal/data-inventory.md new file mode 100644 index 00000000..aed49306 --- /dev/null +++ b/legal/data-inventory.md @@ -0,0 +1,289 @@ +# ResolutionFlow Data Inventory + +Generated: 2026-05-14 +Repo path: `/config/workspace/resolutionflow` +Scanned commit: `0564646` (branch `feat/public-landing-routing-refactor`) + +> Derived directly from the FastAPI backend, React 19 frontend, and deployment config. Anything ambiguous from the scan is flagged in **Section 5 — Open questions** and must be confirmed by the user before generation. + +--- + +## 1. First-party data (ResolutionFlow as controller) + +These are categories where ResolutionFlow itself decides why and how the data is processed (i.e., its own users, billing, telemetry). + +### 1a. Account identity & authentication + +| Table | Fields | Sensitivity | Retention | +|---|---|---|---| +| `users` | `email` (unique), `password_hash` (bcrypt), `name`, `phone`, `job_title`, `timezone`, `avatar_url`, `logo_data`, `company_display_name`, `role_at_signup`, `last_login`, `email_verified_at`, `deleted_at` (soft) | Direct PII + credential | Indefinite (soft-delete only; no automated purge of soft-deleted rows) | +| `accounts` | `name`, `display_code`, `stripe_customer_id`, `branding_*`, `team_size_bucket`, `primary_psa`, `chat_retention_days` (default 90), `chat_retention_max_count` (default 100), `session_idle_minutes`, `session_absolute_minutes`, `sso_provider`, `sso_config` (JSONB) | Account metadata; tenant boundary | Indefinite | +| `account_invites` | `email`, `code`, `role`, `invited_by_id`, `expires_at`, `revoked_at`, `email_sent_at` | PII (invitee email) | Until expiry/revocation; no automated purge | +| `oauth_identities` | `provider` (google/microsoft), `provider_subject`, `provider_email_at_link`, `user_id` | PII (federated identity binding) | Until manual unlink/account deletion | +| `email_verification_tokens` | `token_hash` (SHA-256), `user_id`, `expires_at`, `used_at` | Auth token (hashed) | Until used or expired; no automated purge of expired rows confirmed | +| `password_reset_tokens` | (parallel structure expected) | Auth token (hashed) | Until used or expired | +| `refresh_tokens` | `token_hash`, `user_id`, `expires_at`, `revoked_at` | Auth token (hashed) | Idle 3d / absolute 14d defaults (overridable per-account); rows persist after expiry — no purge job confirmed | + +**Authentication mechanics:** JWT with HS256, 5-min access tokens, refresh-token rotation (idle 3d / absolute 14d defaults from `Settings.SESSION_*_MINUTES_DEFAULT`). Passwords hashed with bcrypt (12 rounds). OAuth supported for Google and Microsoft. + +### 1b. Authorization & audit + +| Table | Fields | Sensitivity | Retention | +|---|---|---|---| +| `audit_logs` | `user_id`, `account_id`, `action`, `resource_type`, `resource_id`, `details` (JSONB), `ip_address` (up to 45 chars — IPv6) | PII (IP address), behavioral | Indefinite — no purge job | +| `teams`, `team` membership | team metadata | Tenant metadata | Indefinite | + +### 1c. Billing & subscriptions + +| Table | Fields | Sensitivity | Retention | +|---|---|---|---| +| `subscriptions` | `account_id`, `stripe_subscription_id`, `stripe_price_id`, `plan`, `status`, `current_period_*`, `cancel_at_period_end`, `seat_limit` | Billing metadata | Indefinite | +| `plan_billing` | (account billing snapshot fields) | Billing metadata | Indefinite | +| `stripe_events` | `id` (Stripe event id), `event_type`, `payload_excerpt` (JSONB), `processed_at` | Billing metadata | Indefinite (idempotency table) | + +**Card data:** ResolutionFlow does not store card numbers. Stripe Elements (`@stripe/stripe-js` on the frontend) collects card details directly; only Stripe IDs are stored server-side. + +### 1d. Telemetry, AI usage, product behavior + +| Table | Fields | Notes | +|---|---|---| +| `ai_usage` | `user_id`, `account_id`, `conversation_id`, `tier_at_time`, `input_tokens`, `output_tokens`, `estimated_cost_usd`, `succeeded`, `extra_data` (JSONB) | Per-AI-call accounting; no message bodies | +| `feature_flag` / overrides | flag membership | Operational | +| `feedback`, `beta_feedback` | `user_id`, `reaction`, `category`, `text`, `page_url`, `session_id` | User-supplied free-text feedback | +| `survey_invite`, `survey_response` | survey content | User-supplied | +| `session_rating` | 1–5 star rating + feedback text | User-supplied | + +### 1e. Marketing / pre-signup leads + +| Table | Fields | Notes | +|---|---|---| +| `sales_leads` | `email`, `name`, `company`, `team_size`, `message`, `source`, `posthog_distinct_id`, `status` | Contact/demo requests from public pages | +| (beta signup endpoint) | similar — see `api/endpoints/beta_signup.py` | Pre-onboarding leads | + +### 1f. Frontend telemetry (client-originated, server-collected) + +- **PostHog (`posthog-js`)** initialized in [main.tsx](frontend/src/main.tsx#L17): `autocapture: true`, `capture_pageview: true`, `capture_pageleave: 'if_capture_pageview'`, `persistence: 'localStorage+cookie'`. Identified by `user.id`, grouped by `account_id`. Sends to `us.i.posthog.com` (US instance). Web Vitals events also forwarded. +- **Sentry (`@sentry/react` + `sentry-sdk[fastapi]`)**: error tracking + 20% traces sample rate in prod, Session Replay at 1% normal / 100% error sessions; **`maskAllText: false`, `blockAllMedia: false`** ([instrument.ts](frontend/src/instrument.ts#L9-L12)), so replays can contain visible text and media unless an explicit `data-sentry-mask` is added. +- **Backend Sentry:** `send_default_pii=True` ([main.py:18](backend/app/main.py#L18)) — Sentry receives user identifiers, request paths, and request body fragments by default. + +--- + +## 2. Customer data (ResolutionFlow as processor) + +Data flowing through ResolutionFlow on behalf of MSP customers. The MSP is the controller; ResolutionFlow processes on their instruction. These are the categories where the DPA's processor obligations apply. + +### 2a. Troubleshooting session content + +| Table | Fields | Notes | +|---|---|---| +| `ai_sessions` | `intake_content` (JSONB: text, image URLs, log contents, ticket data), `problem_summary`, `problem_domain`, `conversation_messages` (full LLM history JSONB), `system_prompt_snapshot`, `pending_task_lane`, `resolution_summary`, `resolution_action`, `resolution_note_markdown`, `escalation_reason`, `escalation_package` (JSONB), `escalation_package_markdown`, `session_feedback`, `ticket_data` (PSA snapshot) | **High sensitivity** — may contain end-client names, hostnames, IPs, emails, internal credentials, ticket bodies. The MSP's clients are the data subjects here, not the MSP. | +| `ai_session_steps` | per-step actions/notes | Same sensitivity as parent | +| `ai_session_embeddings` | pgvector embeddings | Derived from session content | +| `ai_conversations` | AI flow-builder wizard state, `messages` (JSONB), `wizard_state`, `generated_tree`, `expires_at` | **TTL: 24h, purged hourly** via `_cleanup_expired_ai_conversations` | +| `sessions` (legacy guided sessions) | `tree_snapshot`, `path_taken`, `decisions`, `custom_steps`, `scratchpad`, `next_steps`, `ticket_number`, `client_name`, `outcome_notes` | Same sensitivity | +| `session_branches`, `fork_point`, `session_handoff`, `session_facts`, `session_resolution_output`, `session_suggested_fixes` | branching + handoff artifacts | Same sensitivity | +| `assistant_chat`, `copilot_conversation` | open-ended chat threads with the model | Same sensitivity. **Retention: account-configurable, default 90 days OR 100-chat cap** ([retention_cleanup.py](backend/app/services/retention_cleanup.py)). Pinned chats are exempt. | +| `ai_chat_session` | parallel chat session table | Auto-archived after 30 days of inactivity ([main.py:45](backend/app/main.py#L45)) — archived (not deleted) | +| `kb_import` | uploaded KB content for ingestion | Same sensitivity | + +### 2b. Flow / Tree authoring + +| Table | Notes | +|---|---| +| `trees`, `tree`, `tree_embedding`, `tree_share`, `tree_chunker`, `draft_template`, `template_tree`, `step_library`, `step_category`, `script_template`, `script_builder_session`, `network_diagram`, `flow_proposal`, `platform_step`, `supporting_data` | Customer-authored content. Tenant-isolated except for `template_trees`, `platform_steps`, `script_categories`, `plan_feature_defaults`, `accounts` (global tables). | + +### 2c. PSA connection & ticket data + +| Table | Fields | Notes | +|---|---|---| +| `psa_connections` | `provider`, `display_name`, `site_url`, `company_id`, **`credentials_encrypted`** (Fernet, key derived via HKDF from `SECRET_KEY` — see [encryption.py](backend/app/services/psa/encryption.py)), `flowpilot_settings` | One per account. Application-layer encryption of credentials at rest. | +| `psa_activity_log`, `psa_post_log`, `psa_member_mapping` | PSA push history, retry state | Internal audit of round-trip writes | + +PSA ticket bodies, contact names, company names, and notes flow into `ai_sessions.ticket_data` and `intake_content`. **ConnectWise is the MSP's existing data source, not a ResolutionFlow subprocessor** (see `references/msp-context.md` and Subprocessor section below). When ResolutionFlow writes back (resolution notes, escalation packages), that's the MSP instructing a write to their own data store — `resolution_note_external_id` and `escalation_package_external_id` capture the round-trip pointer. + +### 2d. File uploads + +| Table | Fields | Storage | Retention | +|---|---|---|---| +| `file_uploads` | `account_id`, `uploaded_by`, `session_id`, `filename`, `content_type`, `size_bytes`, `storage_key`, `ai_description`, `extracted_content`, `content_summary` | Railway Object Storage (S3-compatible) bucket `resolutionflow-uploads` | Indefinite — no automated purge surfaced | +| `attachments` | session attachments | Same | Indefinite | + +PDFs and DOCX files are text-extracted (`pypdf`, `python-docx`). Images are resized via Pillow and forwarded as multimodal blocks to Claude — but per repo convention, images are **not stored in conversation history**. + +### 2e. Notifications & emails + +| Table | Notes | +|---|---| +| `notifications` | In-app notifications | +| `notification_log` | Delivery attempts | +| `notification_config` | Per-user/account preferences | + +Transactional email is sent via **Resend** (`resend==2.21.0`, `RESEND_API_KEY`). FROM address: `invites@resolutionflow.com`. Sales-lead notifications go to `sales@resolutionflow.com`. + +--- + +## 3. Subprocessors + +Each row reflects what the scan found in the codebase or deployment configuration. + +### Subprocessor: Railway +- **Service type:** Application + database hosting + S3-compatible object storage +- **Data categories:** All stored data — primary PostgreSQL database (DB name `railway` in prod, alias `patherly`), application compute, uploaded files in `resolutionflow-uploads` bucket +- **Location:** US (Railway default region; confirm specific region used) +- **Detected via:** `backend/railway.toml`, `frontend/railway.toml`, `DATABASE_URL`, `STORAGE_*` env vars +- **DPA reference:** https://railway.com/legal/dpa + +### Subprocessor: Anthropic +- **Service type:** LLM API (Claude — Sonnet 4.6 standard tier, Haiku 4.5 fast tier) +- **Data categories:** Session intake text, conversation history, ticket data, file content (PDF/DOCX text + resized image bytes), prompt cache contents +- **Location:** US +- **Purpose:** FlowPilot guided troubleshooting, AI flow builder, chat, resolution-note + escalation-package generation, fact synthesis, template extraction, network-diagram generation, script builder +- **Detected via:** `ANTHROPIC_API_KEY`, `anthropic>=0.40.0`, `AI_PROVIDER='anthropic'` in [config.py:153-208](backend/app/core/config.py#L153-L208) +- **DPA reference:** https://www.anthropic.com/legal/commercial-dpa +- **[LEGAL REVIEW: verify training carve-out]** Anthropic's commercial API tier does not train on customer data by default — confirm the tier in use matches before publishing. + +### Subprocessor: Google AI (Gemini) +- **Service type:** LLM API fallback +- **Data categories:** Same as Anthropic when `AI_PROVIDER='gemini'` +- **Location:** US +- **Detected via:** `GOOGLE_AI_API_KEY`, `google-genai>=1.0.0`, `AI_MODEL_GEMINI='gemini-2.5-flash'` +- **DPA reference:** https://cloud.google.com/terms/data-processing-addendum +- **[LEGAL REVIEW: confirm whether Gemini is currently active]** The code path exists but Anthropic is the configured default. Disclose either as "primary + fallback" or remove if Gemini key is not provisioned in prod. + +### Subprocessor: Voyage AI +- **Service type:** Embeddings (RAG / similarity search) +- **Data categories:** Text excerpts from sessions and flows used to compute vector embeddings (`voyage-3.5`, 1024 dimensions) +- **Location:** US +- **Detected via:** `VOYAGE_API_KEY`, `voyageai>=0.3.0`, `EMBEDDING_MODEL='voyage-3.5'` +- **DPA reference:** https://www.voyageai.com/dpa **[LEGAL REVIEW: confirm Voyage DPA URL and zero-retention status]** + +### Subprocessor: Stripe +- **Service type:** Payment processing +- **Data categories:** Billing contact, card details (collected by Stripe Elements client-side — ResolutionFlow does not see PANs), Stripe customer/subscription IDs, webhook event payloads +- **Location:** US (Stripe Global) +- **Detected via:** `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`, `stripe==14.3.0`, `@stripe/stripe-js` +- **DPA reference:** https://stripe.com/legal/dpa +- **PCI:** SAQ-A scope (Stripe Elements). ResolutionFlow never receives full card data. + +### Subprocessor: Resend +- **Service type:** Transactional email +- **Data categories:** Recipient email addresses, email subject + body content (account invites, password resets, email verification, feedback notifications, sales-lead notifications) +- **Location:** US +- **Detected via:** `RESEND_API_KEY`, `resend==2.21.0`, `FROM_EMAIL='invites@resolutionflow.com'` +- **DPA reference:** https://resend.com/legal/dpa + +### Subprocessor: Sentry +- **Service type:** Error tracking + performance tracing + Session Replay +- **Data categories:** Stack traces, request paths, **user IDs and request body fragments (`send_default_pii=True`)**, browser session replays at 1%/100% sampling with text + media **unmasked**, breadcrumbs +- **Location:** US (Sentry SaaS) — **[LEGAL REVIEW: confirm Sentry data region]** +- **Detected via:** `SENTRY_DSN`, `sentry-sdk[fastapi]>=2.54.0`, `@sentry/react`, [main.py:14-26](backend/app/main.py#L14-L26), [instrument.ts](frontend/src/instrument.ts) +- **DPA reference:** https://sentry.io/legal/dpa/ +- **[LEGAL REVIEW: PII posture]** `send_default_pii=True` + unmasked Session Replay is broader than typical defaults. Either narrow the configuration (recommended: enable text masking on sensitive routes; set `send_default_pii=False`; add Sentry scrubbing rules for `intake_content`, `conversation_messages`, `ticket_data`) or disclose explicitly. + +### Subprocessor: PostHog +- **Service type:** Product analytics + Web Vitals +- **Data categories:** User ID, account ID (as group), email + name + plan + role on identify, page paths, autocaptured DOM interactions, custom events +- **Location:** US (`us.i.posthog.com` instance) +- **Detected via:** `posthog-js`, `@posthog/react`, [main.tsx:17-23](frontend/src/main.tsx#L17-L23), `VITE_PUBLIC_POSTHOG_KEY` +- **DPA reference:** https://posthog.com/dpa +- **Cookies:** PostHog sets a first-party cookie because `persistence: 'localStorage+cookie'` is configured — **disclosure required in Cookie Policy and consent flow** if EU/UK visitors are reachable on public pages. + +### Subprocessor: Google Fonts +- **Service type:** Font CDN +- **Data categories:** Visitor IP address (Google Fonts exposes IPs to Google) +- **Location:** Global Google CDN +- **Detected via:** [index.html:11-13](frontend/index.html#L11-L13) — `fonts.googleapis.com` + `fonts.gstatic.com` +- **DPA reference:** Google's terms (Google Fonts is normally treated as a service, not a controller-controller share, but the IP exposure is a known disclosure) +- **[LEGAL REVIEW: Schrems II / EU caution]** For EU/UK visitors, Google Fonts loaded over `fonts.googleapis.com` is a recurring GDPR enforcement target. Consider self-hosting (Bunny Fonts or bundling) to remove the disclosure. + +### NOT subprocessors (deliberately excluded) + +- **ConnectWise PSA** — MSP customer's existing data source/controller, not a ResolutionFlow subprocessor (see `references/msp-context.md`). Disclose as "data source the customer authorizes ResolutionFlow to read from and, when instructed, write to." +- **Autotask, HaloPSA** — same classification (provider stubs exist in `services/psa/`; current scan suggests ConnectWise is the only live provider, but **[OPEN QUESTION]** below asks the user to confirm) +- **GoDaddy / DNS registrar** — DNS only, no traffic proxy +- **GitHub mirror, Gitea** — source control, no customer data flows +- **Microsoft Learn MCP** — read-only documentation lookup; the MCP server returns docs to ResolutionFlow, no customer data flows to Microsoft as part of this integration + +--- + +## 4. Cookies and trackers + +| Name / pattern | Type | Set by | Purpose | Strict-necessary? | +|---|---|---|---|---| +| `ph_*` (PostHog) | Persistent first-party | `posthog-js` (`persistence: 'localStorage+cookie'`) | Analytics — distinct ID, session, feature-flag state | **No** — requires consent under GDPR/UK PECR | +| `access_token`, `refresh_token` | **localStorage** (NOT cookies) | `authStore`, `OAuthCallbackPage`, `SessionExpiryToast` | Auth bearer tokens for API calls | Strict-necessary | +| `theme-storage` | localStorage | `index.html` inline script | UI theme preference | Strict-necessary (preference) | +| `rf-editor-fullscreen` | localStorage | `Modal.tsx` | UI preference | Strict-necessary (preference) | +| `rf-intended-plan` | localStorage | `RegisterPage.tsx` | Carry pricing-page selection into signup | Strict-necessary (UX) | +| `recentFlows` storage key | localStorage | `lib/recentFlows.ts` | Recent flow MRU | Strict-necessary (UX) | +| Step-feedback "hint shown" flag | localStorage | `StepFeedback.tsx` | Suppress repeated coachmark | Strict-necessary (UX) | +| Rated-sessions list | localStorage | `csatUtils.ts` | Hide CSAT widget after rating | Strict-necessary (UX) | +| Escalation-queue "seen" set | localStorage | `EscalationQueue.tsx` | Mark notifications seen | Strict-necessary (UX) | + +**Backend-set cookies:** None found. Auth uses bearer tokens delivered in JSON, stored client-side in localStorage. No `Set-Cookie` headers issued by FastAPI middleware. + +**Note on auth tokens in localStorage:** This is a known security-disclosure point. Tokens in localStorage are accessible to any JS running on the page; XSS would expose them. Disclose in the security section of the Privacy Policy as a deliberate architecture choice. + +--- + +## 5. Retention and deletion logic — confirmed gaps + +What the scan **confirms** has automated retention: +- **AI flow-builder wizard conversations** (`ai_conversations`): 24h TTL, purged hourly ([scheduler.py:118](backend/app/core/scheduler.py#L118)) +- **Assistant chats** (`assistant_chat`): account-configurable retention, default **90 days OR 100 chats** (whichever first) for non-pinned chats; cleanup runs daily ([retention_cleanup.py](backend/app/services/retention_cleanup.py)) +- **AI chat sessions** (`ai_chat_session`): auto-archived (not deleted) after 30 days idle ([main.py:45](backend/app/main.py#L45)) + +What the scan **confirms is missing**: +- `audit_logs` — no purge job; grows indefinitely (IP addresses retained forever) +- `refresh_tokens` — expired/revoked rows persist; no GC +- `email_verification_tokens`, `password_reset_tokens` — no purge of expired rows confirmed +- `file_uploads` and Railway storage objects — no lifecycle policy surfaced +- `ai_sessions` and full session content (intake, conversation, ticket snapshots) — no automated purge; tied only to soft-delete of the owning user +- `ai_usage` — telemetry retained indefinitely +- `sales_leads`, `beta_feedback`, `survey_response` — no purge job +- `notifications`, `notification_log` — no purge job +- `stripe_events` — idempotency table grows indefinitely +- Soft-deleted users (`users.deleted_at`) — no hard-delete job; `hard_delete_user` exists as a super-admin endpoint only + +**Account deletion behavior** ([accounts.py:524](backend/app/api/endpoints/accounts.py#L524)): owner-only, blocked if other members exist, performs **soft-delete of the user** + revoke all refresh tokens. Account row, audit logs, sessions, files, etc. are **not** purged. + +**[LEGAL REVIEW: GDPR Article 5(1)(e) storage limitation]** A controller-facing claim of "we retain data only as long as necessary" would conflict with the current state. The Privacy Policy should either (a) describe the actual state honestly ("retained until you request deletion") with an explicit deletion-on-request commitment and SLA, or (b) implement scheduled purge for the categories above before publishing. + +--- + +## 6. Logging & encryption posture + +**Logging** (`app/core/middleware.py` `RequestLoggingMiddleware`, `ErrorLoggingMiddleware`): request paths and errors logged via Python `logging`. **[LEGAL REVIEW: confirm whether request bodies are logged]** — if yes, structured PII (emails, ticket content) ends up in `logs/` and on Railway. Audit `logger.info` / `logger.exception` call sites to verify. + +**At-rest encryption:** +- **PSA credentials** (`psa_connections.credentials_encrypted`): application-layer Fernet encryption, key derived from `SECRET_KEY` via HKDF. ✅ Confirmed. +- **Railway-managed Postgres + Object Storage**: disk-level encryption from the platform. **[LEGAL REVIEW: verify Railway encryption attestation]** before claiming "encrypted at rest" globally. +- **No additional column-level encryption** for `password_hash` (bcrypt is the protection there), `ai_sessions.*`, `intake_content`, `conversation_messages`, etc. + +**In transit:** HTTPS on prod (`resolutionflow.com`, `api.resolutionflow.com`). Backend serves over HTTP locally; production CORS gated by `ALLOW_RAILWAY_ORIGINS` for PR envs. + +**Security headers:** `SecurityHeadersMiddleware` present with CSP in report-only mode (`CSP_REPORT_ONLY=True` default). + +--- + +## 7. Open questions for the user + +These must be confirmed before generation: + +1. **Live PSA providers** — `services/psa/` has stubs for ConnectWise, Autotask, and HaloPSA. Is only ConnectWise active in production, or are Autotask/HaloPSA also enabled? (Affects DPA and Privacy Policy data-source list.) +2. **Gemini status** — is `GOOGLE_AI_API_KEY` provisioned in prod, or is Anthropic the sole live LLM provider? (Disclose one or both.) +3. **Voyage AI status** — is `VOYAGE_API_KEY` provisioned in prod? Embeddings are a live code path but the key may not be set. +4. **Sentry data region** — US or EU? (Affects EU data-transfer disclosure.) +5. **Railway region** — which region is the prod project deployed in? (Affects data-location claims.) +6. **Jurisdictions targeted** — should we assume EU/UK reachable (default yes for B2B SaaS), California (yes), other US states (Virginia, Colorado, Connecticut, Texas — newer laws now in force)? Anything to exclude? +7. **Business entity** — what is the legal entity name and address that should appear as "Controller" / "Service Provider" on the documents? (Required for binding contact / notices section.) +8. **DPO / privacy contact email** — is there a dedicated address (e.g., `privacy@resolutionflow.com`), or should we use `support@` / `michael@resolutionflow.com`? +9. **Whether Microsoft Learn MCP usage is enabled in prod** — `ENABLE_MCP_MICROSOFT_LEARN=True` default. The integration retrieves docs only (no customer data outflow), but worth confirming. +10. **Non-codebase tools** — does ResolutionFlow use any of: Zapier/n8n/Make, HubSpot/Salesforce CRM, DocuSign, Help Scout/Zendesk, transcription/voice (Whisper, Eleven Labs), customer-data-platform tooling? None found in code; common to be configured elsewhere. +11. **AGE: Children's data** — confirm ResolutionFlow has no users under 13 (US COPPA) / 16 (UK GDPR). Should be implicit for a B2B MSP product but the policy needs to state it. +12. **Free tier / EULA** — confirm whether the product accepts unauthenticated visitors who can submit anything other than the public sales-lead form and public flow shares. +13. **Backup retention** — Railway Postgres backups (point-in-time recovery window) extend effective retention. Confirm the PITR window and disclose. + +--- + +**Stop point.** Per the skill workflow, generation is blocked on user confirmation of this inventory. Please review and either confirm or correct each section — and answer Section 7 — before I move to Phase 2 (classification) and Phase 3 (generation). diff --git a/legal/dpa.md b/legal/dpa.md new file mode 100644 index 00000000..e2f70a20 --- /dev/null +++ b/legal/dpa.md @@ -0,0 +1,334 @@ +# Data Processing Agreement + +**Effective Date:** 2026-05-14 +**Version:** 1.0 + +> **DRAFT — not legal advice.** This DPA was generated from a code scan with reasonable defaults. Commercial-risk provisions (audit rights, breach SLA, sub-processor notice period, liability allocation) are flagged for attorney calibration. + +This Data Processing Agreement ("DPA") supplements the [Terms of Service](terms-of-service.md) ("Terms") between **ResolutionFlow LLC** ("ResolutionFlow," "we," "us," or "Processor") and the customer identified in the applicable subscription or order form ("Customer," "you," "your," or "Controller"). This DPA applies to ResolutionFlow's processing of Personal Data on behalf of Customer in connection with the Services. + +Where the Terms and this DPA conflict regarding the processing of Personal Data, this DPA controls. + +## 1. Definitions + +Terms not defined here have the meanings given in the Terms. The following terms have the meanings set forth below: + +- **"Applicable Data Protection Laws"** means all laws and regulations applicable to the parties' processing of Personal Data, including the EU General Data Protection Regulation 2016/679 ("GDPR"), the UK Data Protection Act 2018 and UK GDPR ("UK GDPR"), the California Consumer Privacy Act as amended by the California Privacy Rights Act ("CCPA/CPRA"), and other US state comprehensive privacy laws in force. +- **"Customer Data"** means the data Customer or its authorized users submit to the Services or that ResolutionFlow retrieves on Customer's behalf from connected systems. +- **"Personal Data"** means any information within Customer Data relating to an identified or identifiable natural person, as defined under Applicable Data Protection Laws. "Personal Information" has the meaning under CCPA/CPRA and is included within Personal Data for purposes of this DPA. +- **"Data Subject"** means an identified or identifiable natural person to whom Personal Data relates. +- **"Sub-processor"** means any third party engaged by ResolutionFlow to process Personal Data on Customer's behalf. +- **"Processing"** has the meaning given under Applicable Data Protection Laws and includes any operation performed on Personal Data, whether automated or not. +- **"Data Subject Request"** means a request from a Data Subject to exercise rights under Applicable Data Protection Laws. + +## 2. Roles and scope + +### 2.1 Roles + +For Customer Data containing Personal Data: +- **Customer is the Controller** (or, where Customer itself processes on behalf of its own customers, the Processor) of the Personal Data. +- **ResolutionFlow is the Processor** acting on Customer's documented instructions. + +Under CCPA/CPRA terminology, ResolutionFlow acts as a **Service Provider** to Customer. + +### 2.2 Chain of processing + +Customer acknowledges that, where Customer is itself a Processor acting on behalf of its own end-clients (for example, an MSP processing PSA data on behalf of its IT-service clients), ResolutionFlow acts as a Sub-processor to Customer in that chain. Customer represents that it has the legal authority under its agreements with its end-clients to appoint ResolutionFlow as a Sub-processor. + +### 2.3 Subject matter and details + +The subject matter, duration, nature and purpose of processing, types of Personal Data, and categories of Data Subjects are described in **Annex A**. + +### 2.4 Documented instructions + +ResolutionFlow processes Personal Data only on Customer's documented instructions. The Terms, this DPA, and Customer's configuration and use of the Services constitute Customer's complete and final instructions for processing. + +If ResolutionFlow believes an instruction violates Applicable Data Protection Laws, it will inform Customer without undue delay and may suspend that processing. + +### 2.5 No use for ResolutionFlow's purposes + +ResolutionFlow will not retain, use, sell, share, or disclose Personal Data for any purpose other than performing the Services for Customer, except: +- For internal use to operate, secure, and improve the Services in a manner consistent with Customer's instructions and using de-identified or aggregated information +- As required by law + +ResolutionFlow will not "sell" or "share" Personal Data as those terms are defined under CCPA/CPRA, and will not combine Customer's Personal Data with personal information received from other sources except as permitted under CCPA/CPRA service-provider exemptions. + +## 3. ResolutionFlow obligations + +### 3.1 Compliance + +ResolutionFlow will comply with Applicable Data Protection Laws in performing its obligations under this DPA. + +### 3.2 Confidentiality + +ResolutionFlow will ensure that personnel authorized to process Personal Data are bound by written confidentiality obligations. + +### 3.3 Security measures + +ResolutionFlow will implement and maintain appropriate technical and organizational measures designed to protect Personal Data, as described in **Annex B**. + +### 3.4 Sub-processors + +#### 3.4.1 Authorization + +Customer authorizes ResolutionFlow to engage the Sub-processors listed in **Annex C** (the current list is also published at the [Subprocessor List](subprocessor-list.md)). + +#### 3.4.2 Notification of new Sub-processors + +ResolutionFlow will provide at least **30 days' prior notice** of any new Sub-processor by updating the Subprocessor List and notifying Customer through the Services or by email. `[LEGAL REVIEW: 30 days is a common baseline; some enterprise buyers will insist on 60-90 days]` + +#### 3.4.3 Objection + +Customer may object to a new Sub-processor on reasonable data-protection grounds by notice to support@resolutionflow.com within the notice period. If the parties cannot resolve the objection in good faith, Customer may terminate the affected portion of the Services and receive a prorated refund of prepaid fees for the unused period. + +#### 3.4.4 Sub-processor obligations + +ResolutionFlow will impose on each Sub-processor data-protection obligations materially equivalent to those in this DPA, and ResolutionFlow remains liable to Customer for the performance of its Sub-processors' obligations. + +### 3.5 Assistance to Customer + +ResolutionFlow will provide reasonable assistance to Customer in: +- Responding to Data Subject Requests, taking into account the nature of the processing and information available to ResolutionFlow +- Ensuring compliance with security, breach-notification, and data-protection-impact-assessment obligations under Applicable Data Protection Laws + +ResolutionFlow may charge for assistance that exceeds the scope of standard Services usage, at its then-current rates. + +### 3.6 Data Subject Requests + +If ResolutionFlow receives a Data Subject Request directly relating to Customer Data, it will promptly forward the request to Customer and will not respond except on Customer's instruction or as required by law. + +### 3.7 Personal Data Breach + +ResolutionFlow will notify Customer of a confirmed Personal Data Breach affecting Personal Data without undue delay and in any event within **72 hours** of confirming the Breach. The notification will include, to the extent known: + +- Nature of the Breach +- Categories and approximate number of Data Subjects and records affected +- Likely consequences +- Measures taken or proposed to address the Breach + +ResolutionFlow will provide reasonable cooperation in Customer's regulatory notifications. `[LEGAL REVIEW: 72 hours follows the GDPR baseline; some enterprise buyers demand 24-48 hours]` + +### 3.8 Audit rights + +#### 3.8.1 Information + +ResolutionFlow will make available to Customer all information reasonably necessary to demonstrate compliance with this DPA, including by providing copies of relevant third-party audit reports (such as SOC 2, when available). + +#### 3.8.2 Audit + +Where third-party reports are insufficient to satisfy Customer's legitimate audit needs, Customer (or an independent auditor mutually agreed by the parties) may, on at least 30 days' written notice and not more than once per 12-month period, conduct an audit of ResolutionFlow's data-protection practices. Audits will be conducted during business hours, will not unreasonably interfere with ResolutionFlow's operations, and will be subject to confidentiality obligations. Customer bears its own audit costs. + +#### 3.8.3 SCC audits + +For audits required under Standard Contractual Clauses, those clauses prevail to the extent of inconsistency. + +## 4. Customer obligations + +### 4.1 Lawful basis + +Customer represents and warrants that it has all necessary rights, consents, and legal bases to share Personal Data with ResolutionFlow and to authorize the processing described in this DPA. This includes, where Customer is acting on behalf of its own end-clients, having appropriate agreements in place authorizing ResolutionFlow's processing. + +### 4.2 Permitted data categories + +Customer will not submit (and will use reasonable efforts to prevent its users from submitting) to the Services: + +- Special categories of Personal Data under GDPR Article 9 (or analogous categories under other Applicable Data Protection Laws) except as appears incidentally in ticket content +- Protected Health Information as defined under HIPAA, unless a Business Associate Agreement is in place between Customer and ResolutionFlow +- Payment card data, other than Stripe-collected payment information for ResolutionFlow's own billing +- Government-issued identifiers (Social Security numbers, passport numbers, driver's license numbers) of third parties + +### 4.3 Data Subject communications + +Customer is responsible for providing notices to Data Subjects regarding ResolutionFlow's processing under this DPA, and for responding to Data Subject Requests, with ResolutionFlow's reasonable assistance as set out in Section 3.5. + +## 5. International transfers + +### 5.1 Transfers from the EEA, UK, and Switzerland + +To the extent ResolutionFlow's processing involves transfer of Personal Data from the European Economic Area, United Kingdom, or Switzerland to a country not subject to an adequacy decision, the parties agree: + +- For EEA transfers: the **Standard Contractual Clauses** (Module 2 — Controller to Processor, or Module 3 — Processor to Processor, as applicable) approved by the European Commission in Decision 2021/914 are incorporated by reference and apply as if set out in full. +- For UK transfers: the **UK Addendum** to the EU SCCs (issued by the UK ICO) is incorporated by reference. +- For Swiss transfers: the SCCs apply with appropriate adaptations under Swiss law. + +The Module(s), the parties' roles, optional clauses, and Annex content are specified in **Annex D**. + +### 5.2 EU-US Data Privacy Framework + +If ResolutionFlow becomes certified to the EU-US Data Privacy Framework (or its UK or Swiss extensions), the parties may, at Customer's election, rely on that certification as the transfer mechanism in lieu of the SCCs. `[LEGAL REVIEW: consider applying for DPF certification when eligible]` + +## 6. Term, return, and deletion + +### 6.1 Term + +This DPA applies for as long as ResolutionFlow processes Personal Data on Customer's behalf. + +### 6.2 Return or deletion + +Upon termination of the Services, ResolutionFlow will, at Customer's election: + +- Make Personal Data available for export through the Services for **30 days** following termination, OR +- Provide a one-time export of Personal Data in a structured, commonly-used format upon Customer's reasonable request + +After the export window, ResolutionFlow will delete or anonymize Personal Data, except where retention is required by law. ResolutionFlow will certify deletion upon request. `[LEGAL REVIEW: today, deletion of account-scoped Personal Data on customer offboarding is not automated. Either implement scheduled deletion or rewrite this section to describe the actual flow. We strongly recommend the former before signing this DPA with enterprise customers.]` + +### 6.3 Backup retention + +Customer acknowledges that Personal Data may persist in routine backups for up to **90 days** after deletion, and that ResolutionFlow will not actively delete Personal Data from backups but will not restore deleted Personal Data from backups except to recover from a system failure. + +## 7. Liability + +The Terms govern allocation of liability between the parties, except that any provisions of the SCCs governing liability between the parties under those clauses apply in addition to (and not in limitation of) the Terms. + +## 8. Order of precedence + +To the extent of any conflict regarding the processing of Personal Data, the order of precedence is: + +1. The Standard Contractual Clauses (where they apply) +2. This DPA +3. The Terms + +## 9. General + +### 9.1 Modifications + +ResolutionFlow may update this DPA to reflect changes in Applicable Data Protection Laws or its operations, provided that no update will materially reduce the protections afforded to Customer or Personal Data without Customer's consent. + +### 9.2 Severability + +If any provision of this DPA is held unenforceable, the remaining provisions remain in effect. + +### 9.3 Entire agreement on processing + +This DPA, together with its Annexes and the SCCs (where applicable), constitutes the entire agreement between the parties regarding processing of Personal Data under the Services. + +### 9.4 Notices + +Notices under this DPA may be sent to support@resolutionflow.com. For service of legal process or any notice requiring a physical mailing address for ResolutionFlow LLC, contact support@resolutionflow.com to receive the appropriate address. + +--- + +# Annex A — Description of Processing + +**Subject matter:** Processing of Personal Data within Customer Data as necessary to provide the Services. + +**Duration:** For the term of Customer's subscription, plus the export and deletion windows in Section 6. + +**Nature and purpose:** Hosting, storing, transmitting, displaying, indexing, embedding, analyzing, and otherwise processing Customer Data as necessary to deliver the Services. This includes AI-assisted features that involve transmission of Personal Data to designated Sub-processors, generation of resolution notes and escalation packages, computation of vector embeddings for similarity search, and write-back to Customer's PSA platform when instructed by Customer. + +**Types of Personal Data (illustrative, not exhaustive):** + +- Names, email addresses, phone numbers, and job titles of Customer's personnel +- Names, email addresses, phone numbers, and contact records of Customer's end-clients and their personnel (as they appear in PSA records, tickets, and notes) +- Tenant/site identifiers (e.g., ConnectWise company IDs), configuration data, and infrastructure identifiers (hostnames, IP addresses) that appear in ticket content +- Free-text content submitted by Customer's users to ticket intake, AI sessions, chat threads, scratchpads, escalation reasons, resolution summaries, feedback, and similar fields +- Files uploaded by Customer's users (PDFs, DOCX, images, log files) and text extracted from them +- AI conversation transcripts that incorporate any of the above +- Audit-log records of Customer's users' actions, including IP addresses + +**Categories of Data Subjects:** + +- Customer's personnel and authorized users +- Customer's end-clients and their personnel (where Customer is itself a Processor or service provider to those end-clients) +- Other individuals whose Personal Data appears in tickets, communications, files, or system records routed through the Services + +**Sensitive data:** Customer is instructed not to submit sensitive categories. Incidental sensitive data appearing in free-text ticket content is processed only as part of the broader ticket and is not used by ResolutionFlow for any sensitive-data-specific purpose. + +--- + +# Annex B — Technical and Organizational Measures + +`[LEGAL REVIEW: this annex mirrors actual implementation as of the scan date. Update before contracting with each new enterprise customer.]` + +ResolutionFlow implements the following technical and organizational measures: + +### B.1 Encryption + +- **In transit:** TLS for all production traffic between Data Subject browsers, the Services, and Sub-processors +- **At rest — infrastructure layer:** Customer Data stored in PostgreSQL and object storage is encrypted at rest by our infrastructure provider (Railway). `[LEGAL REVIEW: verify Railway encryption-at-rest attestation]` +- **At rest — application layer:** PSA integration credentials (e.g., ConnectWise public and private keys) are additionally encrypted at the application layer using Fernet (AES-128-CBC + HMAC-SHA256) with a key derived from a server-side secret via HKDF-SHA256 +- **Passwords:** stored as bcrypt hashes with a work factor of 12; plaintext passwords are never stored + +### B.2 Access control + +- Role-based access control within Customer accounts (super_admin, account owner, admin, engineer, viewer) +- Tenant isolation at the database layer using PostgreSQL row-level security keyed on `account_id` +- Principle of least privilege for ResolutionFlow personnel access +- Authentication of users via email + password (bcrypt-hashed) or federated OAuth (Google, Microsoft) +- JWT-based session tokens with short-lived access tokens (5 minutes) and rotated refresh tokens bounded by idle and absolute session limits + +### B.3 Network and infrastructure security + +- Hosting on infrastructure providers that maintain industry-standard security certifications +- Network segmentation between production and non-production environments +- Patching and dependency management processes +- Monitoring for unauthorized access via centralized logs and error monitoring +- Rate limiting on authentication endpoints + +### B.4 Operational security + +- Confidentiality obligations binding all personnel with access to Personal Data +- Documented incident response procedures `[LEGAL REVIEW: confirm an incident response plan is documented]` +- Security awareness expected of personnel `[LEGAL REVIEW: formalize annual training when team grows]` + +### B.5 Data isolation + +- Logical separation of Customer Data between Customer tenants enforced at the database (RLS) and application layers +- Global tables (such as platform-wide flow templates and step categories) contain no Personal Data +- Cross-tenant access is restricted to ResolutionFlow super-admin personnel acting under audit + +### B.6 Auditing and logging + +- Audit logs of administrative actions, role changes, account ownership transfers, and security-sensitive events +- Error and performance monitoring via Sentry with sampled traces and Session Replay +- Product-analytics events via PostHog identified by user and account + +### B.7 Business continuity + +- Regular backups of the production database maintained by Railway +- Backups retained for up to **90 days** +- Recovery procedures exercised periodically `[LEGAL REVIEW: formalize an RTO/RPO target]` + +### B.8 Sub-processor oversight + +- Data Processing Agreement in place with each Sub-processor +- Periodic review of Sub-processors' security postures + +--- + +# Annex C — Authorized Sub-processors + +The authoritative list, including data categories, regions, and links to each Sub-processor's DPA, is published at the [Subprocessor List](subprocessor-list.md) and is incorporated into this DPA by reference. Customer will be notified of changes as described in Section 3.4. + +As of the Effective Date, the authorized Sub-processors are: + +| Sub-processor | Service | Location | DPA | +|---|---|---|---| +| Railway Corp. | Application hosting, PostgreSQL, object storage | US | https://railway.com/legal/dpa | +| Anthropic, PBC | LLM API for AI features | US | https://www.anthropic.com/legal/commercial-dpa | +| Voyage AI, Inc. | Embedding API | US | `[LEGAL REVIEW: confirm DPA URL]` | +| Stripe, Inc. | Payment processing | US | https://stripe.com/legal/dpa | +| Resend | Transactional email | US | https://resend.com/legal/dpa | +| Functional Software, Inc. (Sentry) | Error monitoring, traces, Session Replay | US | https://sentry.io/legal/dpa/ | +| PostHog, Inc. | Product analytics | US | https://posthog.com/dpa | +| Google LLC | Google Fonts CDN | Global | Google's standard terms | + +--- + +# Annex D — Standard Contractual Clauses Configuration + +For transfers under the EU SCCs (Commission Decision 2021/914): + +- **Module:** Module 2 (Controller-to-Processor) for transfers where Customer is the Controller; Module 3 (Processor-to-Processor) for transfers where Customer is itself a Processor for its own end-clients. The applicable Module is determined by Customer's role. +- **Clause 7 (Docking clause):** Not applicable. +- **Clause 9 (Use of sub-processors):** Option 2 (general written authorization) applies; the notice period is as set out in Section 3.4.2 of this DPA. +- **Clause 11 (Redress):** Option (independent dispute-resolution body) is **not** elected. +- **Clause 17 (Governing law):** The law of Ireland. `[LEGAL REVIEW: Irish law is the most common SCC choice; counsel may prefer another EU member state]` +- **Clause 18 (Choice of forum and jurisdiction):** The courts of Ireland. `[LEGAL REVIEW]` +- **Annex I.A. (List of Parties):** The data exporter is Customer; the data importer is ResolutionFlow LLC. +- **Annex I.B. (Description of Transfer):** As set out in Annex A of this DPA. +- **Annex I.C. (Competent supervisory authority):** Irish Data Protection Commission. `[LEGAL REVIEW: confirm based on Customer's location]` +- **Annex II (Technical and Organisational Measures):** As set out in Annex B of this DPA. +- **Annex III (Sub-processors):** As set out in Annex C of this DPA. + +For UK transfers, the **UK Addendum** to the EU SCCs (Information Commissioner's Office, "International Data Transfer Addendum to the EU Commission Standard Contractual Clauses") is incorporated, and Table 4 of the Addendum is completed such that neither party may end the Addendum as set out in Section 19 unless otherwise agreed. `[LEGAL REVIEW: confirm Table 4 election with counsel]` diff --git a/legal/implementation-verification.md b/legal/implementation-verification.md new file mode 100644 index 00000000..caf4f1d0 --- /dev/null +++ b/legal/implementation-verification.md @@ -0,0 +1,119 @@ +# Implementation Verification + +Generated: 2026-05-14 +Scanned commit: `0564646` on `feat/public-landing-routing-refactor` + +This document checks every concrete claim in the generated legal documents against what the code actually does. Each row is marked: + +- ✅ **Confirmed** — code clearly supports the claim +- ⚠️ **Partial** — the code supports a narrower or related claim; the language is acceptable but tighten if possible +- ❌ **Not implemented** — the claim is aspirational; either build it or rewrite the claim +- ❓ **Cannot verify in scan** — depends on a runtime config, deployment posture, or external attestation the scan can't reach + +> A claim that overpromises is worse than one that underpromises. Anything ❌ must be resolved (built or rewritten) before publication. + +--- + +## Privacy Policy + +| Claim | Source in docs | Reality | Verdict | +|---|---|---|---| +| Passwords are bcrypt-hashed with 12 rounds; plaintext never stored | §3.1, §9 | `BCRYPT_ROUNDS=12` ([config.py:86](../backend/app/core/config.py#L86)); `User.password_hash` ([user.py:36](../backend/app/models/user.py#L36)) | ✅ | +| PSA integration credentials encrypted at the application layer using Fernet (AES-128-CBC + HMAC), key derived via HKDF from `SECRET_KEY` | §3.1, §9; DPA Annex B.1 | [encryption.py](../backend/app/services/psa/encryption.py) | ✅ | +| TLS for production traffic | §9; DPA Annex B.1 | Hosted at `api.resolutionflow.com` / `resolutionflow.com` via Railway with HTTPS | ❓ (depends on Railway domain config; verify) | +| Tenant isolation enforced by PostgreSQL row-level security | §9; DPA Annex B.2 / B.5 | RLS referenced in [PROJECT_CONTEXT.md:206](../.ai/PROJECT_CONTEXT.md#L206) as "Phase 4 RLS"; `account_id` scoping pervasive | ✅ | +| Access tokens stored in `localStorage` rather than HTTP-only cookies | §9 | Confirmed in [authStore.ts:47-48](../frontend/src/store/authStore.ts#L47-L48), [OAuthCallbackPage.tsx:100-101](../frontend/src/pages/OAuthCallbackPage.tsx#L100-L101) | ✅ | +| 5-minute access tokens, idle 3d / absolute 14d refresh defaults | §6 retention table; Cookie Policy §2.1 | [config.py:69-79](../backend/app/core/config.py#L69-L79) | ✅ | +| Account deletion soft-deletes the user and revokes refresh tokens; account-scoped content **not** automatically purged | §6 (drafted as a `[LEGAL REVIEW]` flag) | [accounts.py:524-567](../backend/app/api/endpoints/accounts.py#L524-L567) — confirms the soft-delete + token revoke; no purge of `audit_logs`, `ai_sessions`, etc. | ⚠️ disclosed accurately as a flagged gap; ❌ if you intend to claim "we delete your data" | +| AI flow-builder wizard conversations purged 24h after creation | §6 retention | [scheduler.py:118-136](../backend/app/core/scheduler.py#L118-L136), hourly job | ✅ | +| Assistant chat threads retained 90 days OR 100-chat cap (account-configurable), pinned exempt | §6 retention | [retention_cleanup.py](../backend/app/services/retention_cleanup.py); defaults in [account.py:40-45](../backend/app/models/account.py#L40-L45) | ✅ | +| AI chat sessions auto-archived after 30 days idle | §6 retention | [main.py:45-63](../backend/app/main.py#L45-L63) | ✅ (note: archived, not deleted — disclosed accurately) | +| Audit logs retention | §6 (flagged) | No purge job — indefinite | ❌ — fix or rewrite | +| Refresh-token row cleanup | §6 retention | Rows persist after expiry/revoke | ❌ — fix or rewrite (data-inventory open item) | +| Email-verification / password-reset token cleanup | §6 retention | Rows persist after expiry/use | ❌ — fix or rewrite | +| File-upload deletion on account deletion | §6 retention | `file_uploads` rows + Railway Object Storage objects retained | ❌ — fix or rewrite | +| Stripe never sees full card data; we hold only Stripe customer/subscription IDs | §3.4; Subprocessor List Stripe row | `@stripe/stripe-js` on frontend (Elements pattern); backend stores `stripe_customer_id`, `stripe_subscription_id` only ([account.py:28](../backend/app/models/account.py#L28), [subscription.py](../backend/app/models/subscription.py)) | ✅ | +| PostHog initialized with `persistence: 'localStorage+cookie'`; identified by `user.id`, grouped by `account_id`; US instance | §3.2; Cookie Policy §2.3 | [main.tsx:17-23](../frontend/src/main.tsx#L17-L23); [analytics.ts:34-40](../frontend/src/lib/analytics.ts#L34-L40) | ✅ | +| Sentry: backend `send_default_pii=True`; replay 1%/100% with text + media unmasked | §3.2 (disclosed); Subprocessor List | [main.py:14-26](../backend/app/main.py#L14-L26); [instrument.ts:9-12](../frontend/src/instrument.ts#L9-L12) | ✅ (disclosed accurately; ⚠️ recommend narrowing — see Attorney Checklist A2) | +| Anthropic is the sole live LLM provider | §5.1; Subprocessor List | `AI_PROVIDER='anthropic'` ([config.py:159](../backend/app/core/config.py#L159)); user-confirmed Gemini not provisioned | ✅ | +| Voyage AI is the live embedding provider | Subprocessor List | `VOYAGE_API_KEY`, `EMBEDDING_MODEL='voyage-3.5'` ([config.py:219-221](../backend/app/core/config.py#L219-L221)); user-confirmed key set | ✅ | +| No model training on Customer Data (Anthropic, Voyage) | ToS §3.4; Subprocessor List | Public terms commitment of each subprocessor; not enforceable from our side | ❓ — re-verify subprocessor terms before each publish | +| Resend is the transactional email provider; address `invites@resolutionflow.com` | Subprocessor List | [config.py:97-99](../backend/app/core/config.py#L97-L99) | ✅ | +| Google Fonts loaded over CDN → IP exposed to Google | §5.1; Subprocessor List; Cookie Policy §2.5 | [index.html:11-13](../frontend/index.html#L11-L13) | ✅ | +| Microsoft Learn MCP retrieves public docs only; no Customer Data egress | Subprocessor List "What is NOT" | `ENABLE_MCP_MICROSOFT_LEARN=True` ([config.py:216](../backend/app/core/config.py#L216)); the MCP search query string is the only outbound payload | ⚠️ partial — the query string itself can include AI-session context. Disclosed at a high level; if Customer Data text could be substantively included in a query, consider listing MS Learn as a subprocessor. | +| Backup retention 90 days | §9 backup language; DPA §6.3 | User-stated target; depends on Railway PITR window configuration | ❓ — verify Railway PITR configuration matches | + +--- + +## Terms of Service + +| Claim | Source | Reality | Verdict | +|---|---|---|---| +| Owner, admin, engineer, viewer role hierarchy; team-admin gate separately | §2.3 | `permissions.py`, `User.account_role` ([user.py:25-52](../backend/app/models/user.py#L25-L52)) | ✅ | +| Only owner can delete the account; deletion blocked if other members remain | §9.2 | [accounts.py:524-548](../backend/app/api/endpoints/accounts.py#L524-L548) | ✅ | +| Removed members are moved to a personal account on the free tier | §2.3 | [accounts.py:231-254](../backend/app/api/endpoints/accounts.py#L231-L254) | ✅ | +| ConnectWise PSA integration available | §1, §3.1, §8 | `services/psa/connectwise/`; only live PSA provider per user | ✅ | +| AI features integrate Anthropic; outputs may include errors | §4.2 | Code confirms Anthropic integration; honest disclosure | ✅ | +| 30-day export window post-termination | §9.4 | No automated export-window enforcement in code | ❌ — needs implementation or rewrite | +| Stripe handles payment processing | §5.3 | `@stripe/stripe-js` + `STRIPE_*` env vars | ✅ | +| Auto-renewal of subscriptions | §5.2 | Stripe Subscriptions semantics | ✅ | +| 30-day notice for price changes | §5.5 | Operational commitment; not code-enforced | ❓ — operational | +| MFA disclosure (not required) | (Privacy Policy §9 — accurate omission) | No MFA code path detected | ✅ | + +--- + +## DPA + +| Claim | Source | Reality | Verdict | +|---|---|---|---| +| Application-layer encryption for PSA credentials | Annex B.1 | Confirmed (above) | ✅ | +| RLS for tenant isolation | Annex B.2/B.5 | Confirmed (above) | ✅ | +| Authorized sub-processors list matches reality | Annex C | Matches Subprocessor List (Anthropic, Voyage, Stripe, Resend, Sentry, PostHog, Railway, Google Fonts) | ✅ | +| 72-hour breach notification SLA | §3.7 | Operational commitment | ❓ — define an internal detection-to-notify procedure to make this credible | +| Audit reports (SOC 2) available | §3.8.1 | No SOC 2 today | ⚠️ language says "when available," which is honest | +| Customer Data deleted after 30-day export window | §6.2 | Not implemented — see Privacy Policy table above | ❌ — flagged in Attorney Checklist A1 | +| 90-day backup retention | §6.3 | User-stated; depends on Railway PITR config | ❓ | +| SCC Module 2 / Module 3 incorporation | §5.1 + Annex D | Drafting only — no Customer signed instance yet | ❓ — operational | + +--- + +## Subprocessor List + +| Subprocessor | Listed correctly? | Notes | +|---|---|---| +| Railway | ✅ | Hosting + DB + Object Storage all in one entry | +| Anthropic | ✅ | LLM API for FlowPilot and AI features | +| Voyage AI | ✅ | Embedding provider; confirm DPA URL with attorney | +| Stripe | ✅ | Payment processor | +| Resend | ✅ | Transactional email | +| Sentry | ✅ | Error + Session Replay; see A2 about config | +| PostHog | ✅ | Product analytics; US instance | +| Google Fonts | ✅ | Disclosed; consider self-hosting (A3) | +| Gemini / Google AI | Omitted (correct) | Not provisioned in prod | +| OpenAI | Omitted (correct) | Not detected | +| Autotask, HaloPSA | Omitted (correct) | Not live | +| ConnectWise | Disclosed as non-subprocessor (correct) | Customer-controlled data source | +| Microsoft Learn MCP | Disclosed as non-subprocessor | Verified: doc-retrieval only | + +--- + +## Cookie Policy + +| Item | Reality | Verdict | +|---|---|---| +| `access_token` and `refresh_token` in localStorage | [authStore.ts:47-48, 86-87](../frontend/src/store/authStore.ts) and others | ✅ | +| `theme-storage`, `rf-editor-fullscreen`, `rf-intended-plan`, `recentFlows`, step-feedback flag, rated-sessions, escalation-queue seen | All confirmed by grep | ✅ | +| `ph_*` cookie set by PostHog due to `persistence: 'localStorage+cookie'` | [main.tsx:17-23](../frontend/src/main.tsx#L17-L23) | ✅ | +| Sentry described as telemetry-only, not cookie-setting | Default Sentry browser SDK behavior matches description | ✅ | +| Google Fonts disclosed | [index.html:11-13](../frontend/index.html#L11-L13) | ✅ | +| Consent mechanism for EU/UK | **Not implemented** | ❌ — see Attorney Checklist A3 | + +--- + +## Net verdict + +**Safe to share with an attorney as a starting draft.** Do not publish to the public website until the items marked ❌ are resolved by either: +1. Building the missing behavior (recommended path for A1 deletion-on-offboarding, A3 consent banner, A2 Sentry config tightening), OR +2. Rewriting the relevant paragraph to describe the actual behavior with no overclaim. + +The factual scaffolding (subprocessors, encryption posture, retention reality, cookie inventory) is accurate. The remaining work is commercial-risk calibration and a small number of high-priority implementation gaps. diff --git a/legal/privacy-policy.md b/legal/privacy-policy.md new file mode 100644 index 00000000..fa391ff0 --- /dev/null +++ b/legal/privacy-policy.md @@ -0,0 +1,215 @@ +# Privacy Policy + +**Effective Date:** 2026-05-14 +**Last Updated:** 2026-05-14 +**Version:** 1.0 + +> **DRAFT — not legal advice.** This document was generated from a code scan and is intended for review by a qualified attorney before publication. Sections marked `[LEGAL REVIEW]` require attorney calibration. + +## 1. Introduction + +ResolutionFlow LLC ("ResolutionFlow," "we," "us," or "our") provides a software-as-a-service platform that helps managed service providers (MSPs) triage, resolve, and document IT support tickets. This Privacy Policy explains how we handle personal information when you visit our website, create an account, or use the ResolutionFlow Services. + +**Important — two distinct data categories.** ResolutionFlow processes two distinct categories of data, and they are governed by different documents: + +1. **Personal information of our direct users** — for example, the MSP technician or owner who creates a ResolutionFlow account. This Privacy Policy describes how we handle that information. +2. **Customer Data** that flows through the Services on behalf of an MSP customer — for example, ticket data retrieved from a connected ConnectWise PSA instance, file uploads, and the contents of AI sessions. We process Customer Data as a service provider under the [Data Processing Agreement](dpa.md) ("DPA") between ResolutionFlow and the MSP, and the MSP's own privacy notices govern the relationship with the individuals whose data appears in that Customer Data. + +If you are an end-client of an MSP and have questions about how the MSP uses ResolutionFlow to handle data about you, please contact the MSP directly. ResolutionFlow does not have a direct relationship with end-clients of our customers. + +## 2. Who we are + +**Controller:** ResolutionFlow LLC +**Country of operation:** United States +**Contact:** support@resolutionflow.com + +We do not publish a physical mailing address on this page. For service of legal process, written notice, or to receive our address for a contractual purpose, please contact support@resolutionflow.com. + +`[LEGAL REVIEW: appoint and disclose a Data Protection Officer if required under GDPR Article 37, and an EU/UK representative under Article 27 because ResolutionFlow has no EEA or UK establishment]` + +## 3. Information we collect + +### 3.1 Information you provide to us + +- **Account information** — your name, email address, and password. We use these to create and authenticate your account and to send transactional messages about the Services. We hash passwords using bcrypt; we never store plaintext passwords. +- **Profile information** — phone number, job title, time zone, avatar image, and (for solo professionals) optional company display name and uploaded logo. Optional; collected to personalize your experience and your ticket outputs. +- **Account / organization information** — the account name, display code, optional team size, optional branding (logo, primary color, company name), and the PSA platform you primarily use. Collected so we can route subscriptions, invites, and integration data correctly. +- **Federated sign-in identifiers** — if you sign in with Google or Microsoft, we receive the provider's subject identifier and the email address the provider returns at the time you link the account, and we store the linkage so we can recognize you on future logins. +- **Integration credentials** — when you connect a ConnectWise PSA instance, you provide your ConnectWise company ID, public key, and private key. We **encrypt these credentials at rest at the application layer using Fernet (AES-128-CBC + HMAC-SHA256), with a key derived from our server secret via HKDF**. We use them only to retrieve and write data on your behalf. `[LEGAL REVIEW: verify encryption claim if material changes are made to services/psa/encryption.py]` +- **Sales / demo requests** — if you submit our contact or demo form, we collect your name, work email, company, optional team size, and any message you choose to send. We use this to contact you and to follow up on your inquiry. +- **Beta / waitlist signups** — if you sign up for our beta or waitlist, we collect your email and any other information you choose to provide. +- **Support communications** — when you contact us at support@resolutionflow.com, we receive the contents of your message and any information you choose to include. +- **Feedback** — if you submit in-product feedback, beta feedback, surveys, or session ratings, we collect what you submit and link it to your account so we can respond and learn from it. + +### 3.2 Information we collect automatically + +- **Usage data** — pages and features you interact with, timestamps of actions, AI-feature inputs and outputs you generate. We use this to understand how the Services are used and to bill the right account for AI usage. +- **Device and connection data** — IP address, browser type, operating system, time zone. We collect this for security, fraud prevention, and to deliver content appropriately. IP addresses are captured in our audit logs and (subject to your sampling rate) in error reports. +- **Authentication and security events** — login attempts, OAuth identity linking, password resets, refresh-token rotations, and administrative actions are recorded in our internal audit log. `[LEGAL REVIEW: today these records are retained indefinitely; we recommend implementing a defined retention window (e.g., 12 months) and stating it here]` +- **Product analytics** — when you use the Services, our analytics provider (PostHog) records page views, feature interactions ("autocapture"), and custom events, identified by your user ID and grouped by your account. Web Vitals (page-load performance metrics) are also captured. +- **Error and performance monitoring** — our error-tracking provider (Sentry) records errors, performance traces, and a sampled subset of browser sessions. By default, our backend sends error reports including user identifiers and request metadata. Our frontend captures Session Replay at 1% of normal sessions and 100% of sessions in which an error occurs; replays may capture visible page contents. `[LEGAL REVIEW: this configuration is broader than typical defaults — see implementation-verification.md. Either narrow the configuration (mask text and media, set send_default_pii=False, add scrubbing rules) or expand this disclosure with specific examples of what may be captured]` + +### 3.3 Information from third-party services + +- **ConnectWise PSA** — when you connect a ConnectWise instance, we retrieve ticket, company, contact, configuration, and note data on your behalf. **This data is Customer Data governed by the DPA, not this Privacy Policy.** ConnectWise is your PSA provider; it is not a ResolutionFlow subprocessor. Your relationship with ConnectWise is governed by your agreement with ConnectWise. +- **Stripe** — when you subscribe, Stripe handles your payment information directly and sends us a customer ID, a subscription ID, billing status, and webhook event metadata. We do not see or store your full payment card number. +- **Google / Microsoft (Sign-in)** — if you choose to sign in via Google or Microsoft, we receive the identifiers described in Section 3.1. + +### 3.4 Information we do not collect + +We do not knowingly collect: + +- Sensitive personal information categories about our direct users in the ordinary course of providing the Services (health data, financial account credentials, biometrics, precise geolocation, government IDs). If a free-text field (for example, a support message or in-product feedback) contains this kind of information because you typed it, we treat the field as ordinary content; we recommend you avoid placing such information into free-text fields. `[LEGAL REVIEW: this is an honest disclosure of incidental risk]` +- Personal information from individuals under 16 years of age. The Services are designed for IT professionals and are not directed to children. +- Full credit card numbers. Payment information is collected and processed directly by Stripe; we receive only a Stripe customer ID, a subscription ID, and billing status. + +## 4. How we use information + +We use personal information for the following purposes, each with the indicated legal basis under GDPR / UK GDPR. Under CCPA/CPRA, we use the same information for the same business and commercial purposes. + +| Purpose | Information used | Legal basis (GDPR) | +|---|---|---| +| Create and operate your account; deliver the Services | Account, profile, federated identity, integration credentials | Contract performance (Art. 6(1)(b)) | +| Authenticate you and secure the Services | Authentication and security events, device/connection data, audit logs | Legitimate interests (Art. 6(1)(f)) — securing the Services | +| Send transactional messages (invites, password resets, verification, billing receipts, security alerts) | Account, email | Contract performance | +| Process subscription billing | Stripe customer ID, billing metadata | Contract performance | +| Respond to your support, demo, sales, beta, or feedback submissions | The submission itself | Contract performance / legitimate interests (responding to your request) | +| Generate AI-assisted outputs (FlowPilot, chat, resolution notes, escalation packages, embeddings, network diagrams, scripts) | Inputs you submit, Customer Data you authorize | Contract performance (provision of Services) | +| Operate product analytics and Web Vitals via PostHog | User identifier, behavioral events, page paths | Legitimate interests + (in the EU/UK) consent where required for non-essential cookies / local storage `[LEGAL REVIEW: a consent banner is required for EU/UK before PostHog initializes]` | +| Operate error monitoring via Sentry | Error reports, request metadata, sampled Session Replay | Legitimate interests (improving and securing the Services) | +| Aggregate usage to improve the Services | Aggregated, de-identified usage data | Legitimate interests | +| Send marketing emails (if you opt in) | Email, name | Consent (you can withdraw at any time) `[LEGAL REVIEW: confirm whether marketing emails are sent today — if so, ensure opt-in capture is recorded]` | +| Comply with legal obligations | As required | Legal obligation (Art. 6(1)(c)) | + +We do not use Customer Data for our own purposes — including model training, advertising, or marketing — except as necessary to provide the Services to the MSP customer that supplied it. AI feature inputs are sent to our AI subprocessor (Anthropic) for the purpose of generating the response; Anthropic does not train its models on these inputs under the API tier we use. `[LEGAL REVIEW: re-verify Anthropic's no-training-on-API-traffic commitment for the current API tier at each publication]` + +## 5. How we share information + +We share personal information only as described below. We do not sell personal information, and we do not share personal information for cross-context behavioral advertising. + +### 5.1 Service providers (subprocessors) + +We share information with carefully selected third parties who process personal information on our behalf to deliver the Services. The complete and current list is at [/legal/subprocessors](subprocessor-list.md). Today, our subprocessors are: + +- **Railway Corp.** (United States) — application and database hosting + S3-compatible object storage for uploaded files +- **Anthropic, PBC** (United States) — large-language-model API for FlowPilot and other AI-assisted features +- **Voyage AI** (United States) — embedding model for similarity search and retrieval-augmented features +- **Stripe, Inc.** (United States) — payment processing +- **Resend** (United States) — transactional and account email delivery +- **Sentry** (United States) — error monitoring, performance traces, and Session Replay +- **PostHog** (United States) — product analytics +- **Google LLC** (Global) — Google Fonts CDN used by our website; receives your IP address as part of loading the fonts `[LEGAL REVIEW: consider self-hosting fonts to remove this disclosure for EU/UK visitors]` + +Each subprocessor is bound by a data processing agreement and processes personal information only on our documented instructions. + +### 5.2 Business transfers + +If ResolutionFlow is involved in a merger, acquisition, financing, or asset sale, personal information may be transferred to the involved parties. We will provide notice through the Services or by email before personal information becomes subject to a materially different privacy policy. + +### 5.3 Legal requirements + +We may disclose personal information when we believe in good faith that disclosure is required by law, regulation, legal process, or government request, or to protect our rights, our users, or the public. + +### 5.4 With your consent + +For any sharing not described above, we will obtain your consent. + +## 6. Data retention + +We retain personal information only as long as needed for the purposes described in this Privacy Policy. The retention picture today is: + +| Category | Retention | +|---|---| +| Account information | For the life of your account, plus up to **90 days** of backup retention after account deletion | +| AI flow-builder wizard conversations | **24 hours** (purged hourly) | +| Assistant chat threads | Account-configurable, **default 90 days** OR a maximum of **100 chats** (whichever first); pinned chats are exempt | +| AI chat sessions inactive for 30 days | Auto-archived; not deleted unless you delete them | +| Stripe webhook event records | Retained for idempotency and audit | +| Audit logs, authentication and security events | `[LEGAL REVIEW: today retained indefinitely; implement a 12-month default and update this row to "12 months"]` | +| AI session content, escalation packages, resolution notes, file uploads, and other Customer Data | Retained for the life of the account; deleted on customer request as described in the DPA | +| Marketing-communication opt-outs | Retained indefinitely so we can honor your preference | +| Billing records | As required by tax and accounting law (typically 7 years in the US) | + +When you delete your account, we soft-delete your user record, revoke your refresh tokens, and stop your access. **`[LEGAL REVIEW: today, the account row and account-scoped content such as audit logs, session content, file uploads, and AI usage records are not automatically purged on account deletion. Either implement scheduled deletion or rewrite this paragraph to describe the actual behavior and provide a deletion-on-request path with a stated SLA. We recommend the former.]`** Personal information may persist in routine backups for up to 90 days after deletion. We will not restore deleted information from backups except to recover from a system failure. + +## 7. Your rights + +Depending on where you live, you may have some or all of the following rights regarding your personal information: + +- **Right to know / access** — request a copy of the personal information we hold about you +- **Right to correct** — request that we correct inaccurate personal information +- **Right to delete** — request that we delete your personal information +- **Right to portability** — receive your personal information in a structured, machine-readable format +- **Right to restrict or object to processing** — limit how we process your personal information in certain circumstances +- **Right to opt out of sale or sharing for advertising** — we do not sell personal information or share it for cross-context behavioral advertising; if this ever changes, we will offer an opt-out +- **Right to limit use of sensitive personal information** — under CPRA, where applicable +- **Right to withdraw consent** — where processing is based on consent, you may withdraw it at any time without affecting prior processing +- **Right to non-discrimination** for exercising any of these rights +- **Right to appeal** — if we deny a rights request, you may appeal by replying to our response with "Appeal" +- **Right to lodge a complaint with a supervisory authority** — EU/UK residents may contact their national data protection authority (for example, the UK's Information Commissioner's Office) + +To exercise these rights, email us at **support@resolutionflow.com** with the subject "Privacy Rights Request." We will respond within 45 days (extendable by an additional 45 days for complex requests) as required by applicable law. We may request information sufficient to verify your identity before responding. + +You may designate an authorized agent to make a request on your behalf, subject to identity verification. + +We treat Global Privacy Control (GPC) browser signals as an opt-out of sale or sharing of personal information. + +## 8. International data transfers + +ResolutionFlow LLC is based in the United States, and our infrastructure is hosted in the United States. When you use the Services, your personal information will be transferred to and processed in the United States, which may have different data protection laws than your home country. + +For transfers of personal information from the European Economic Area, United Kingdom, or Switzerland to the United States, we rely on: + +- The **Standard Contractual Clauses** approved by the European Commission in Decision 2021/914 (Module 2 or Module 3, as applicable to the parties' roles) +- The **UK Addendum** to the EU Standard Contractual Clauses for UK transfers +- Equivalent safeguards required by Swiss law for Swiss transfers + +`[LEGAL REVIEW: consider EU-US Data Privacy Framework certification when ResolutionFlow LLC qualifies; until then SCCs are the baseline transfer mechanism. Designate an Art. 27 EU/UK representative if required.]` + +## 9. Security + +We implement technical and organizational measures designed to protect personal information against unauthorized access, alteration, disclosure, or destruction. These include: + +- **Encryption in transit** using TLS for all production traffic +- **Encryption at rest** — Railway-managed Postgres and Object Storage are encrypted at rest at the infrastructure layer, and we additionally apply **application-layer Fernet encryption to stored PSA integration credentials** (the keys we hold on your behalf to talk to ConnectWise) `[LEGAL REVIEW: verify Railway's encryption-at-rest attestation]` +- **Password hashing** using bcrypt with 12 rounds; we never store plaintext passwords +- **Authentication tokens** delivered as bearer tokens to your browser; we store hashes (not the tokens themselves) on the server +- **Role-based access control** at the application layer (super_admin / owner / admin / engineer / viewer), and PostgreSQL row-level security for tenant isolation between accounts +- **Audit logging** of administrative actions +- **Periodic security review** of subprocessors +- **OAuth-based sign-in** options via Google and Microsoft + +We do not currently require multi-factor authentication. `[LEGAL REVIEW: consider whether to disclose MFA explicitly once available, or to require MFA for admin/owner roles]` + +We deliberately store our short-lived access and refresh tokens in your browser's `localStorage` rather than in HTTP-only cookies. This choice carries a known trade-off: tokens in `localStorage` are accessible to any JavaScript running on the page, so a successful cross-site-scripting (XSS) attack against the Services could expose them. We mitigate this risk with content-security headers, short access-token lifetimes, idle and absolute session limits, and bulk token revocation on password change. `[LEGAL REVIEW: this is an honest disclosure; calibrate as needed]` + +No security measure is perfect. If we become aware of a personal data breach affecting your information, we will notify you and supervisory authorities as required by applicable law. + +## 10. Cookies and similar technologies + +We use cookies and similar technologies on the Services. See the [Cookie Policy](cookie-policy.md) for the full list. + +In short: we use authentication tokens stored in your browser to keep you signed in; we store a small number of UI preferences in your browser's local storage; and our product analytics provider (PostHog) sets one cookie alongside its `localStorage` data when you use authenticated parts of the Services. We do not use advertising cookies or cross-context behavioral advertising trackers. + +## 11. Children's privacy + +The Services are not directed to individuals under 16 years of age. We do not knowingly collect personal information from children under 16. If you believe we have collected information from a child under 16, please contact us at support@resolutionflow.com and we will delete it. + +## 12. Changes to this Privacy Policy + +We may update this Privacy Policy from time to time. We will notify you of changes by posting the updated Privacy Policy with a new "Last Updated" date. For material changes affecting how we use your personal information, we will provide notice through the Services or by email at least **30 days** before the change takes effect. + +Your continued use of the Services after the effective date constitutes acceptance of the updated Privacy Policy. + +## 13. Contact us + +For privacy questions or to exercise your rights, contact us at **support@resolutionflow.com**. + +For California residents: +- See Section 7 for your CCPA/CPRA rights. +- You may designate an authorized agent. +- We do not sell or share personal information for cross-context behavioral advertising. + +For EU / UK residents: +- You have the right to lodge a complaint with your national data protection authority. +- `[LEGAL REVIEW: name the Art. 27 EU and UK representatives once appointed]` diff --git a/legal/subprocessor-list.md b/legal/subprocessor-list.md new file mode 100644 index 00000000..edf64453 --- /dev/null +++ b/legal/subprocessor-list.md @@ -0,0 +1,82 @@ +# ResolutionFlow Subprocessor List + +**Effective Date:** 2026-05-14 +**Last Updated:** 2026-05-14 +**Version:** 1.0 + +> **DRAFT — not legal advice.** This list reflects subprocessors active in the codebase as of the scan date. It must be kept current; new subprocessors require advance customer notice as set out in the DPA. + +This page lists the third-party subprocessors that ResolutionFlow LLC uses to process Customer Data in providing the Services. Each subprocessor is bound by a data processing agreement that imposes obligations materially equivalent to those in our [Data Processing Agreement](dpa.md). + +Existing customers receive at least **30 days' notice** of new subprocessors and may object on reasonable data-protection grounds as set out in the DPA. + +## Infrastructure subprocessors + +| Subprocessor | Service | Data categories processed | Region | +|---|---|---|---| +| Railway Corp. | Application hosting, PostgreSQL database hosting, and S3-compatible object storage for uploaded files | All account data, all Customer Data stored or processed by the Services, file uploads in the `resolutionflow-uploads` bucket | United States | + +DPA: https://railway.com/legal/dpa + +## AI and machine-learning subprocessors + +| Subprocessor | Service | Data categories processed | Region | +|---|---|---|---| +| Anthropic, PBC | Large-language-model API (FlowPilot, chat assistant, resolution-note generation, escalation-package generation, fact synthesis, script-builder, network-diagram generation, template extraction) | Prompts submitted to AI features, which may contain Customer Data including PSA ticket content, configuration details, file content extracted from uploads, resized images supplied to multimodal features, conversation history within an AI session | United States | +| Voyage AI, Inc. | Embedding model for similarity search and retrieval-augmented features | Text excerpts from your flows, sessions, and knowledge content used to compute vector embeddings (`voyage-3.5`) | United States | + +DPAs: +- Anthropic: https://www.anthropic.com/legal/commercial-dpa +- Voyage AI: contact subprocessor for current DPA `[LEGAL REVIEW: confirm Voyage AI DPA URL]` + +**Important — no model training on Customer Data.** We use Anthropic's API at a commercial tier that does not train Anthropic's models on Customer Data. Voyage AI processes embedding requests transactionally. We do not authorize either subprocessor to use Customer Data for any purpose other than producing the requested response. `[LEGAL REVIEW: re-verify the no-training stance against each subprocessor's current public terms each time this list is republished]` + +## Payment and billing subprocessors + +| Subprocessor | Service | Data categories processed | Region | +|---|---|---|---| +| Stripe, Inc. | Payment processing and subscription billing | Customer billing contact, Stripe customer ID, payment method details (collected directly by Stripe — ResolutionFlow does not store full card numbers), subscription transactions, webhook event payloads | United States | + +DPA: https://stripe.com/legal/dpa + +## Communication subprocessors + +| Subprocessor | Service | Data categories processed | Region | +|---|---|---|---| +| Resend | Transactional and account email delivery (account invites, password resets, email verification, billing-related messages, internal sales-lead and feedback notifications) | Recipient email addresses, message subject and body | United States | + +DPA: https://resend.com/legal/dpa + +## Operational subprocessors + +| Subprocessor | Service | Data categories processed | Region | +|---|---|---|---| +| Functional Software, Inc. (dba Sentry) | Error monitoring, performance traces, and Session Replay | Error reports, stack traces, request metadata, user identifiers, sampled browser session replays (1% of normal sessions, 100% of sessions in which an error occurred); see implementation-verification.md for the current configuration | United States | +| PostHog, Inc. | Product analytics, autocapture, page-view tracking, and Web Vitals reporting | User identifier, account identifier (as a group), behavioral events, page paths, autocaptured DOM interactions, performance metrics | United States (`us.i.posthog.com`) | +| Google LLC | Google Fonts CDN (font assets loaded by ResolutionFlow's public website) | Visitor IP address (exposed to Google as part of font requests) | Global Google CDN | + +DPAs: +- Sentry: https://sentry.io/legal/dpa/ +- PostHog: https://posthog.com/dpa +- Google: Google's standard terms + +`[LEGAL REVIEW: Google Fonts loaded over fonts.googleapis.com is a recurring GDPR enforcement target; consider self-hosting fonts to remove this row]` + +## What is NOT a subprocessor + +The following are referenced for completeness but are **not** ResolutionFlow subprocessors: + +- **ConnectWise PSA** — When you connect a ConnectWise instance, ResolutionFlow retrieves data from that instance under your authorization. ConnectWise is your PSA provider, not our subprocessor. Your relationship with ConnectWise is governed by your agreement with ConnectWise. +- **DNS and domain registrars** — These providers hold ResolutionFlow's domain records but do not process Customer Data. +- **Microsoft Learn (Model Context Protocol)** — When AI features benefit from Microsoft technical documentation, ResolutionFlow's backend retrieves public Microsoft Learn content. No Customer Data is sent to Microsoft as part of this lookup; only the search query string formed from the AI session is sent. +- **Customer-side integrations** that you connect to ResolutionFlow are governed by your agreements with those third parties. + +## Changes to this list + +We update this list when we add, remove, or materially change subprocessors. We notify existing customers of new subprocessors as set out in the DPA. The "Effective Date" above reflects the most recent change. + +Historical versions are available on request from support@resolutionflow.com. + +## Questions + +Questions about subprocessors? Contact **support@resolutionflow.com**. diff --git a/legal/terms-of-service.md b/legal/terms-of-service.md new file mode 100644 index 00000000..966354f2 --- /dev/null +++ b/legal/terms-of-service.md @@ -0,0 +1,287 @@ +# Terms of Service + +**Effective Date:** 2026-05-14 +**Version:** 1.0 + +> **DRAFT — not legal advice.** This document was generated from a code scan with reasonable defaults. Commercial-risk provisions (liability cap, indemnification, dispute resolution, refunds) are flagged for attorney calibration. + +These Terms of Service ("Terms") govern your use of the ResolutionFlow software-as-a-service platform provided by ResolutionFlow LLC ("ResolutionFlow," "we," "us," or "our"). By creating an account or using the Services, you agree to these Terms. + +If you are entering into these Terms on behalf of a company or other legal entity, you represent that you have the authority to bind that entity to these Terms. In that case, "you" and "your" refer to that entity. + +## 1. The Services + +ResolutionFlow provides a software-as-a-service platform that assists managed service providers (MSPs) in triaging, resolving, and documenting IT support tickets. The Services include: + +- A ticket triage and session interface +- An AI-assisted troubleshooting copilot ("FlowPilot") +- A general-purpose AI assistant for IT workflows +- Integration with the ConnectWise PSA platform +- A knowledge base feature ("Knowledge Flywheel") that derives suggestions from your own resolved sessions +- A flow and procedural builder, a script builder, and a network-diagram builder +- File upload, document analysis, and image analysis for use within sessions + +We may modify, suspend, or discontinue any feature of the Services at any time. For material adverse changes affecting paid subscriptions, we will provide reasonable advance notice through the Services or by email. + +## 2. Eligibility and accounts + +### 2.1 Eligibility + +You must be at least 18 years old and capable of entering into a binding contract to use the Services. The Services are intended for use by businesses providing managed IT services and are not directed to consumers. + +### 2.2 Account responsibilities + +You are responsible for: +- Providing accurate account information and keeping it current +- Maintaining the confidentiality of your credentials +- All activities that occur under your account +- Promptly notifying us of unauthorized access at support@resolutionflow.com + +You may not share your account credentials with any person outside your organization or use another person's account. + +### 2.3 Roles within an account + +An account has one **owner**, optional **admins**, and **engineer** or **viewer** members. Only the owner can delete the account, transfer ownership, or invite others. Members may be removed from an account by the owner; a removed member is moved into a personal account on the free tier. + +## 3. Customer Data and your responsibilities + +### 3.1 Definitions + +"**Customer Data**" means the data that you or your authorized users submit to the Services or that the Services retrieve on your behalf from connected third-party systems including ConnectWise PSA. Customer Data may include personal information about your employees, your end-clients, and your end-clients' employees and contacts. Customer Data includes, without limitation: ticket bodies and notes; intake text, images, and log files; AI session conversation histories; resolution notes and escalation packages; uploaded files; and flows, scripts, and diagrams you create within the Services. + +### 3.2 Your representations regarding Customer Data + +You represent and warrant that: +- You have all rights, consents, and legal bases necessary to share Customer Data with ResolutionFlow and authorize its processing as described in these Terms and the [Data Processing Agreement](dpa.md) ("DPA") +- Your collection and use of Customer Data complies with all applicable laws, including data protection and privacy laws +- You will not submit Customer Data that you are not authorized to process for the purposes for which you use the Services +- You will not submit **Protected Health Information** as defined under HIPAA unless a Business Associate Agreement is in place between you and ResolutionFlow +- You will not submit payment card numbers, government-issued ID numbers, or financial-account credentials of third parties into the Services, except as Stripe handles for ResolutionFlow's own billing +- Where you are acting on behalf of your own end-clients, you have all necessary authority to appoint ResolutionFlow as a sub-processor in your chain of processing + +### 3.3 Ownership + +You retain all right, title, and interest in Customer Data. You grant ResolutionFlow a limited, non-exclusive, worldwide license to host, store, process, transmit, display, analyze, and otherwise use Customer Data solely as necessary to provide the Services and as further described in the DPA. This license terminates when Customer Data is deleted as set out in the DPA, except for de-identified, aggregated data used to operate and improve the Services. + +### 3.4 No model training on Customer Data + +We do not use Customer Data to train our own machine-learning models, and we use AI subprocessors at API tiers that do not train on Customer Data. We use de-identified, aggregated usage information to operate, secure, and improve the Services. + +### 3.5 Data Processing Agreement + +The DPA is incorporated into these Terms by reference and governs ResolutionFlow's processing of personal information within Customer Data. Where these Terms and the DPA conflict regarding personal information processing, the DPA controls. + +## 4. Acceptable use + +### 4.1 Prohibited activities + +You may not, and may not permit anyone to: + +- Use the Services for any unlawful purpose or in violation of any applicable law +- Use the Services to harass, abuse, defame, or stalk any person +- Send spam or other unsolicited messages from or through the Services +- Attempt to gain unauthorized access to the Services or any other user's account +- Reverse engineer, decompile, or attempt to extract the source code of the Services, except where this restriction is prohibited by applicable law +- Interfere with the integrity or performance of the Services, including via denial-of-service attacks, rate-limit evasion, or resource exhaustion +- Use the Services to develop a competing service +- Resell, sublicense, or provide the Services as a service bureau to third parties without our prior written consent +- Use automated means to access the Services other than through documented APIs +- Submit content that infringes a third party's intellectual property or violates a third party's privacy rights +- Use the Services to process Protected Health Information without a Business Associate Agreement in place between you and ResolutionFlow +- Use the Services to process payment card data outside Stripe's payment flow + +### 4.2 AI feature use + +When you use AI-assisted features including FlowPilot, the chat assistant, the script builder, the network-diagram builder, and Knowledge Flywheel outputs, you acknowledge that: + +- AI outputs may contain errors, omissions, or fabricated information ("hallucinations") +- You are responsible for reviewing AI outputs before relying on them, posting them to a PSA ticket, sharing them with an end-client, or running scripts generated by them +- ResolutionFlow does not guarantee the accuracy, completeness, or safety of AI-generated content +- Inputs you submit to AI features are transmitted to AI subprocessors as described in the DPA and Subprocessor List + +### 4.3 Suspension for violation + +We may suspend or terminate your account for violations of this Section 4 with or without notice, depending on the severity of the violation. For clear and active threats to the Services or to other users, we may act immediately. + +## 5. Subscriptions, fees, and payment + +### 5.1 Subscriptions + +The Services are offered on a subscription basis. Subscription details, pricing, and term length are specified at the point of subscription or in a separate order form. + +### 5.2 Billing and renewal + +- Fees are billed in advance for the subscription period (monthly or annually as elected) +- Subscriptions automatically renew at the end of each term unless cancelled before the renewal date +- Fees are non-refundable except as expressly stated or required by law `[LEGAL REVIEW: confirm refund and proration policy — common alternatives include a 14-day satisfaction window or prorated refunds on annual plans]` + +### 5.3 Payment processor + +Payment is processed by Stripe. By providing payment information, you authorize us to charge the applicable fees to your payment method via Stripe. + +### 5.4 Taxes + +Fees are exclusive of taxes. You are responsible for all applicable sales, use, value-added, and similar taxes. + +### 5.5 Price changes + +We may change subscription prices. For existing subscriptions, price changes take effect on the next renewal and we will provide at least **30 days' notice** before the renewal date. + +### 5.6 Trials and free tiers + +We may offer free trials or a free tier. These may be modified or discontinued at any time. Usage of free or trial features is subject to the same Terms. + +## 6. Intellectual property + +### 6.1 Our IP + +ResolutionFlow and its licensors own all right, title, and interest in the Services, including all software, designs, trademarks, models, prompts, and content (other than Customer Data). These Terms do not grant you any rights to our intellectual property except the limited right to use the Services as expressly provided. + +### 6.2 Feedback + +If you provide feedback, suggestions, or ideas about the Services, you grant us a perpetual, irrevocable, worldwide, royalty-free license to use that feedback without obligation to you. + +### 6.3 Trademarks + +You may not use our trademarks, logos, or trade names without our prior written consent, except for descriptive references (e.g., "we use ResolutionFlow"). + +## 7. Privacy + +Our handling of personal information is described in our [Privacy Policy](privacy-policy.md). For Customer Data containing personal information, processing is governed by the [DPA](dpa.md). + +## 8. Third-party services + +The Services may integrate with third-party services that you choose to connect (including ConnectWise PSA). Your use of those third-party services is governed by your agreements with them. We are not responsible for third-party services and disclaim all liability arising from them. + +If a third-party service modifies its API or terms in a way that affects the Services, we may modify or discontinue the integration. We will provide reasonable notice where practicable. + +## 9. Term and termination + +### 9.1 Term + +These Terms remain in effect for as long as you have an account or active subscription with ResolutionFlow. + +### 9.2 Termination by you + +You may terminate your account at any time by following the account-deletion flow in the Services or contacting support@resolutionflow.com. Account deletion requires that you are the sole remaining member of your account. Termination is effective at the end of the current paid subscription period unless otherwise required by law. + +### 9.3 Termination by us + +We may terminate or suspend your account immediately if: + +- You materially breach these Terms (including Section 4) +- You fail to pay fees when due and do not cure within 10 days of notice +- Required by law or government order +- Your use of the Services creates a material legal or security risk to ResolutionFlow or other users + +For other reasons, we may terminate with **30 days' notice**. + +### 9.4 Effect of termination + +Upon termination: + +- Your right to access and use the Services ends +- We will make Customer Data available for export for **30 days** following termination as further described in the DPA `[LEGAL REVIEW: confirm export window aligns with what the Services actually support today]` +- After the export window, we will delete or anonymize Customer Data as described in the DPA, except where retention is required by law +- Sections that by their nature survive termination (intellectual property, confidentiality, indemnification, limitation of liability, dispute resolution) will survive + +## 10. Disclaimers + +`[LEGAL REVIEW: warranty disclaimers are commercial decisions; calibrate with counsel]` + +THE SERVICES ARE PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND, WHETHER EXPRESS, IMPLIED, OR STATUTORY, INCLUDING IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT, EXCEPT TO THE EXTENT THESE DISCLAIMERS ARE PROHIBITED BY APPLICABLE LAW. + +WE DO NOT WARRANT THAT THE SERVICES WILL BE UNINTERRUPTED, ERROR-FREE, OR SECURE, OR THAT AI-GENERATED OUTPUTS WILL BE ACCURATE, COMPLETE, OR FIT FOR ANY PARTICULAR PURPOSE. + +## 11. Limitation of liability + +`[LEGAL REVIEW: liability caps are critical commercial decisions; calibrate to insurance posture and revenue]` + +TO THE MAXIMUM EXTENT PERMITTED BY LAW: + +(a) NEITHER PARTY WILL BE LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR FOR LOST PROFITS, REVENUE, DATA, OR BUSINESS OPPORTUNITIES, ARISING OUT OF OR RELATED TO THESE TERMS OR THE SERVICES. + +(b) EACH PARTY'S TOTAL LIABILITY IN ANY 12-MONTH PERIOD IS LIMITED TO THE FEES PAID OR PAYABLE BY YOU TO RESOLUTIONFLOW IN THE 12 MONTHS PRECEDING THE EVENT GIVING RISE TO THE CLAIM. + +(c) THE LIMITATIONS IN (a) AND (b) DO NOT APPLY TO: (i) BREACH OF CONFIDENTIALITY OBLIGATIONS; (ii) INDEMNIFICATION OBLIGATIONS; (iii) BREACH OF THE DPA; (iv) GROSS NEGLIGENCE OR WILLFUL MISCONDUCT; (v) LIABILITY THAT CANNOT BE LIMITED UNDER APPLICABLE LAW. + +## 12. Indemnification + +`[LEGAL REVIEW: indemnification scope is a major commercial-risk decision; calibrate with counsel]` + +### 12.1 By you + +You will indemnify, defend, and hold harmless ResolutionFlow from any third-party claim arising from: +- Your use of the Services in violation of these Terms or applicable law +- Customer Data, including any allegation that Customer Data infringes a third party's rights or was processed without proper legal basis +- Your representations regarding Customer Data being inaccurate + +### 12.2 By us + +We will indemnify, defend, and hold you harmless from any third-party claim alleging that the Services as provided by us infringe a valid US patent, copyright, or trademark. Our obligation is conditioned on you promptly notifying us of the claim, giving us sole control of the defense, and reasonably cooperating in the defense. + +If we believe the Services may be subject to such a claim, we may at our option: (a) procure the right for you to continue using them; (b) modify them to be non-infringing; or (c) terminate the affected portion of the Services and refund prepaid fees for the unused period. + +This Section 12.2 is your sole remedy for IP infringement claims. + +## 13. Dispute resolution + +`[LEGAL REVIEW: arbitration vs litigation, class-action waiver, and venue selection are major decisions with significant commercial impact — calibrate with counsel and your insurer]` + +### 13.1 Governing law + +These Terms are governed by the laws of the State of Georgia, United States, without regard to conflict-of-law principles. `[LEGAL REVIEW: Georgia chosen as a reasonable default for a Georgia-based LLC; counsel may prefer Delaware]` + +### 13.2 Venue + +Any dispute arising out of or related to these Terms will be brought exclusively in the state or federal courts located in Cobb County, Georgia, and both parties consent to the personal jurisdiction of those courts. `[LEGAL REVIEW: consider arbitration as an alternative — JAMS or AAA — depending on your insurance and litigation posture]` + +### 13.3 Class action waiver + +To the extent permitted by law, each party waives the right to participate in a class, collective, or representative action. + +### 13.4 Time bar + +Any cause of action arising out of or related to these Terms must be brought within one (1) year after the cause of action accrues, except where prohibited by applicable law. + +## 14. General + +### 14.1 Entire agreement + +These Terms, together with the Privacy Policy, Cookie Policy, Subprocessor List, and DPA, constitute the entire agreement between you and ResolutionFlow regarding the Services. + +### 14.2 Modifications to these Terms + +We may modify these Terms by posting the updated Terms and updating the Effective Date. For material changes adverse to existing customers, we will provide at least **30 days' notice** through the Services or by email. Your continued use of the Services after the new Effective Date constitutes acceptance. If you do not accept material changes, you may terminate your account before they take effect. + +### 14.3 Assignment + +You may not assign these Terms without our prior written consent. We may assign these Terms in connection with a merger, acquisition, or sale of substantially all of our assets. Any unauthorized assignment is void. + +### 14.4 Severability + +If any provision of these Terms is held unenforceable, the remaining provisions will remain in effect. + +### 14.5 No waiver + +Our failure to enforce any provision of these Terms is not a waiver of our right to do so later. + +### 14.6 Force majeure + +Neither party is liable for delays or failures caused by events beyond reasonable control, including natural disasters, war, terrorism, civil unrest, government action, pandemic, or major network or infrastructure outages. + +### 14.7 Notices + +Notices to ResolutionFlow must be sent to support@resolutionflow.com. For service of legal process or any notice requiring a physical mailing address, contact us at support@resolutionflow.com to receive the appropriate address. Notices to you may be sent to the email associated with your account. + +### 14.8 Export control + +You will not use or export the Services in violation of US export control laws. + +### 14.9 Headings + +Section headings are for convenience only and do not affect interpretation. + +## 15. Contact + +Questions about these Terms? Contact us at **support@resolutionflow.com**. From d1cf77cd415c2fb996e0c98046a994349a975a17 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 28 May 2026 03:33:32 -0400 Subject: [PATCH 02/42] docs(design): L1 workspace feature spec New seat tier between engineer and viewer. Dedicated /l1 surface (dashboard + walker + drafts) for first-call helpdesk staff. Walk-in intake + PSA queue both produce tickets. Match-or-build pipeline prefers authored flows, then outcome-validated AI drafts, then builds fresh from KB. Three KB connectors: IT Glue, Hudu, SharePoint/OneDrive. Escalation via package + PSA reassign, picked up in chat. Engineer coverage via per-user can_cover_l1 flag with audit-log tagging. Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-05-28-l1-workspace-design.md | 717 ++++++++++++++++++ 1 file changed, 717 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-l1-workspace-design.md diff --git a/docs/superpowers/specs/2026-05-28-l1-workspace-design.md b/docs/superpowers/specs/2026-05-28-l1-workspace-design.md new file mode 100644 index 00000000..6775ef7b --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-l1-workspace-design.md @@ -0,0 +1,717 @@ +# L1 Workspace — Design Spec + +**Date:** 2026-05-28 +**Status:** Draft (pending implementation plan) +**Audience for this doc:** engineers + reviewers building the L1 workspace feature + +--- + +## 1. Summary + +Introduce a dedicated **L1 helpdesk** workspace as a new seat tier in ResolutionFlow. L1 techs walk customers through yes/no decision trees on inbound tickets and phone calls. The platform either matches an existing authored flow, reuses an outcome-validated AI draft, or builds a fresh decision tree in real time from the MSP's ingested knowledge base. Drafts that resolve a call become "outcome-validated" and surface first in the engineer review queue for promotion to authored flows. KB ingestion supports manual upload plus three MSP-native connectors: IT Glue, Hudu, and Microsoft SharePoint/OneDrive. + +This re-introduces the original deterministic tree-walker UX — which had been deprecated in favor of chat-primary FlowPilot — and repositions it as a frontline-tier product surface distinct from the engineer chat surface. + +--- + +## 2. Motivation + +The current ResolutionFlow product funnels every user — regardless of skill tier — into a single chat-primary surface (`AssistantChatPage` mounted at `/pilot`). The chat is excellent for engineers but is the wrong primitive for L1 helpdesk staff who: + +- Take inbound phone calls and need a fast, deterministic click-through UX +- Resolve simple, recurring problems (password resets, mailbox connection issues, VPN disconnects, printer queue clears, etc.) +- Are not authorized to escalate complex issues themselves; they hand off to engineers + +A tree-walker UX serves this audience natively. The substrate already exists in the codebase — decision-tree data model, authoring tools, RAG, KB Accelerator, escalation packaging — but no first-class L1 surface ties it together. This spec defines that surface and the supporting AI/KB pipeline. + +--- + +## 3. Users & roles + +### 3.1 Role hierarchy + +`super_admin > owner > engineer > l1_tech > viewer` + +`l1_tech` is added to the `account_role` enum. Permissions enforced via `app/core/permissions.py` and `app/api/deps.py`. + +### 3.2 What L1 can do + +- Use the `/l1/*` surface +- Open tickets from their queue (PSA-fed or internal) +- Intake walk-in/phone-call problems (creates a ticket as a side effect) +- Walk authored flows and AI-built FlowProposal drafts +- Resolve or escalate a session +- View their own AI drafts list (read-only — outcome tags shown) + +### 3.3 What L1 cannot do + +- See the chat surface (`/pilot`) — sidebar hidden, route 403s +- Author or edit flows +- See `/review-queue` or `/escalations` (engineer inboxes) +- See team analytics (only `/analytics/me`) +- Promote AI drafts (engineers/owners only, via existing review queue) +- Configure KB connectors (owner-only) + +### 3.4 Engineer L1 coverage + +Engineers do NOT see the L1 surface by default. Owners can toggle `users.can_cover_l1 = true` on individual engineer users. Engineers with that flag (and all owners/super_admins) see an "L1 Workspace" entry in their sidebar. Clicking it puts them in `/l1/*` with a sticky banner: *"Covering L1 — actions logged as coverage."* Coverage actions are audit-logged with `acting_as = 'l1_coverage'`. + +Backend dep: `require_l1_or_coverage` = `l1_tech | (engineer AND can_cover_l1) | owner | super_admin`. + +This mirrors the existing orthogonal-flag pattern (`is_team_admin`) — no new architectural concept. + +### 3.5 Billing data model + +- `accounts.l1_seats_purchased INTEGER NOT NULL DEFAULT 0` (new column) +- Existing `accounts.seats_purchased` continues to represent engineer seats +- New Stripe SKU placeholder for L1 seat; actual pricing set in Stripe dashboard out-of-band + +--- + +## 4. Architecture overview + +### 4.1 New components + +**Frontend:** +- `pages/l1/L1Dashboard.tsx` — landing page; ticket queue + describe-the-problem intake +- `pages/l1/L1WalkPage.tsx` — purpose-built walker; yes/no cards, transcript, persistent escalate/resolve +- `pages/l1/L1DraftsPage.tsx` — read-only list of the L1's AI drafts and promotion status +- `pages/l1/L1TicketsPage.tsx` — full-page queue (PSA + internal merged) +- `components/l1/L1CoverageBanner.tsx` — slim banner shown to engineer-coverers + +**Backend:** +- `services/match_or_build.py` — orchestrator (RAG match → fallback to AI build) +- `services/ai_tree_builder.py` — real-time AI tree generation via Anthropic +- `services/kb_connectors/` package — base, registry, encryption, plus `itglue.py`, `hudu.py`, `microsoft_graph.py` +- `services/kb_ingestion_writer.py` — shared writer used by manual upload + all connectors +- `services/kb_ingestion_scheduler.py` — APScheduler job, `max_instances=1`, per-connector sync +- `services/internal_ticket_service.py` — CRUD + status transitions for the no-PSA fallback +- `services/l1_session_service.py` — walking-session lifecycle +- `api/endpoints/l1.py` — L1-role endpoints +- `api/endpoints/kb_connectors.py` — KB connector config endpoints (owner-only for write) + +**Reused / extended:** +- `services/rag_service.py` — flow & KB matching (existing) +- `services/flow_matching_engine.py` — existing +- `services/escalation_package_generator.py` — extended to include walked path, AI draft pointer, KB citations +- `models/FlowProposal` — new columns (see §5) +- `services/psa/` — already supports ticket create + reassign across CW/Autotask/HaloPSA +- `services/embedding_service.py` — used by KB ingestion writer +- New `kb_documents` + `kb_document_chunks` tables for RAG-retrievable document storage, separate from the existing `kb_imports` (which is a document→tree conversion record, not a persistent KB store — see §5) +- Audit log writer — gains `acting_as` field + +### 4.2 Data flow — walk-in / phone-call intake + +``` +L1 types: "User can't connect Outlook after password reset" + POST /api/v1/l1/intake + body: { problem_statement, customer_name?, customer_contact? } + → create ticket + - PSA if configured: psa_provider.create_ticket(...) + - else: internal_tickets row + → match_or_build(account_id, problem_text, ticket_ref) + → rag_service.match_flows(...) → top hit; if score ≥ threshold return as 'flow' + → rag_service.match_proposals(... where validated_by_outcome=true) + → top hit; if score ≥ threshold return as 'proposal' + → ai_tree_builder.build(problem_text, kb_chunks, nearest_flows) + → persist FlowProposal(source='ai_realtime_l1', + linked_ticket_id, + linked_ticket_kind, + validated_by_outcome=false) + → return as 'proposal' + → l1_session_service.start(...) + → return { session_id, target_kind, target_id, intake_type } + → navigate to /l1/walk/{session_id} +``` + +### 4.3 Data flow — PSA-queue intake + +The L1 dashboard polls the L1's PSA queue plus their internal tickets. Clicking a ticket row calls `POST /api/v1/l1/tickets/{ticket_ref}/start` which is the same `match_or_build` path (the `problem_statement` is the ticket subject + description) followed by walker navigation. + +--- + +## 5. Data model + +All new tenant-isolated tables get RLS policies (account-scoped, WITH CHECK). All TIMESTAMPs are `TIMESTAMPTZ`. No `--rev-id` on Alembic; no `--autogenerate` for enum/RLS work. + +### 5.1 `FlowProposal` — extended + +Existing AI-draft model. Add columns: + +| Column | Type | Notes | +|---|---|---| +| `source` | `VARCHAR(30) NOT NULL` | `'ai_realtime_l1' \| 'kb_accelerator' \| 'manual_draft'`. Backfill existing rows to `'manual_draft'`. | +| `linked_ticket_id` | `VARCHAR(64) NULL` | PSA id or internal_tickets UUID (stored as text) | +| `linked_ticket_kind` | `VARCHAR(10) NULL` | `'psa' \| 'internal'` | +| `validated_by_outcome` | `BOOLEAN NOT NULL DEFAULT FALSE` | Flipped to true when L1 resolves and marks helpful=true | +| `walked_path_snapshot` | `JSONB NULL` | Frozen at resolve/escalate; shape `[{node_id, question, answer, l1_note}]` | + +Engineer review queue sort: +```sql +ORDER BY validated_by_outcome DESC, created_at DESC +``` + +### 5.2 `internal_tickets` — new + +``` +id UUID PRIMARY KEY +account_id UUID NOT NULL (RLS-scoped) +created_by_user_id UUID NOT NULL (the L1 who took the call) +customer_name VARCHAR(120) +customer_contact VARCHAR(200) NULL (email or phone, free text) +problem_statement TEXT NOT NULL +status VARCHAR(30) NOT NULL -- 'open' | 'walking' | 'resolved' | 'escalated' +flow_id UUID NULL FK trees +flow_proposal_id UUID NULL FK flow_proposals +ai_session_id UUID NULL FK ai_sessions (set when engineer picks up in chat post-escalation) +assigned_user_id UUID NULL (engineer post-escalation) +resolution_notes TEXT NULL +psa_promoted_ticket_id VARCHAR(64) NULL (set if later promoted to PSA) +created_at TIMESTAMPTZ NOT NULL +updated_at TIMESTAMPTZ NOT NULL +resolved_at TIMESTAMPTZ NULL +``` + +RLS: account-scoped, WITH CHECK on insert/update. + +### 5.3 `kb_connector_configs` — new + +``` +id UUID PRIMARY KEY +account_id UUID NOT NULL (RLS-scoped) +provider VARCHAR(20) NOT NULL -- 'itglue' | 'hudu' | 'microsoft_graph' +display_name VARCHAR(80) NOT NULL +credentials_encrypted BYTEA NOT NULL -- Fernet, same pattern as services/psa/encryption.py +is_active BOOLEAN NOT NULL DEFAULT TRUE +sync_interval_minutes INTEGER NOT NULL DEFAULT 360 +last_sync_at TIMESTAMPTZ NULL +last_sync_status VARCHAR(20) NULL -- 'success' | 'error' | 'running' +last_sync_error TEXT NULL +created_by_user_id UUID NOT NULL +created_at TIMESTAMPTZ NOT NULL +updated_at TIMESTAMPTZ NOT NULL +UNIQUE (account_id, provider, display_name) +``` + +RLS: account-scoped, WITH CHECK. + +### 5.4 New tables: `kb_documents` + `kb_document_chunks` + +The existing `kb_imports` table is a document→tree conversion record (status lifecycle `processing | ready | committed | failed`, target `tree_id`) — designed to turn one document into one authored flow. It is NOT a persistent KB document store and does not power RAG retrieval. + +The L1 feature needs a separate pair of tables that store ingested docs in RAG-retrievable form: + +**`kb_documents`** — one row per ingested document: + +``` +id UUID PRIMARY KEY +account_id UUID NOT NULL (RLS-scoped) +source_kind VARCHAR(20) NOT NULL -- 'upload' | 'paste' | 'itglue' | 'hudu' | 'microsoft_graph' +source_ref VARCHAR(200) NULL -- provider-side document ID for re-sync +connector_config_id UUID NULL FK kb_connector_configs +title VARCHAR(500) NOT NULL +content TEXT NOT NULL -- full post-extraction text +content_hash VARCHAR(64) NOT NULL -- sha256 for change-detection +metadata JSONB NULL -- provider-specific (org_id, drive_id, etc.) +last_synced_at TIMESTAMPTZ NULL +deleted_at TIMESTAMPTZ NULL -- soft-delete on connector removal +created_at TIMESTAMPTZ NOT NULL +updated_at TIMESTAMPTZ NOT NULL +``` + +Unique partial index: `(connector_config_id, source_ref) WHERE source_ref IS NOT NULL`. + +**`kb_document_chunks`** — chunks with embeddings, used by `rag_service.match_kb_chunks`: + +``` +id UUID PRIMARY KEY +document_id UUID NOT NULL FK kb_documents ON DELETE CASCADE +account_id UUID NOT NULL -- denormalized for RLS +chunk_index INTEGER NOT NULL +content TEXT NOT NULL +embedding VECTOR() NOT NULL -- dim matches embedding_service +metadata JSONB NULL -- section title, page number, etc. +created_at TIMESTAMPTZ NOT NULL +UNIQUE (document_id, chunk_index) +``` + +Pgvector index (ivfflat or hnsw) on `embedding`; choice tuned during implementation. + +RLS on both tables: account-scoped, WITH CHECK on insert. + +**Coexistence with `kb_imports`:** when an L1 (or owner) uploads a doc, the system can populate **both** — the existing KBImport pipeline produces a draft tree, and the new ingestion writer additionally chunks+embeds the doc into `kb_documents` for RAG. Both paths share the upload endpoint but write to independent tables. Connectors only write to `kb_documents` (no auto-tree-conversion from synced docs in v1). + +### 5.5 Other column additions + +- `users.can_cover_l1 BOOLEAN NOT NULL DEFAULT FALSE` +- `accounts.l1_seats_purchased INTEGER NOT NULL DEFAULT 0` +- `audit_logs.acting_as VARCHAR(30) NULL` — `'l1_coverage'` when engineer is in coverage mode; null otherwise +- `account_role` enum: add `'l1_tech'` + +### 5.6 Migration ordering + +Six manual Alembic revisions (no `--rev-id`, no `--autogenerate`): + +1. Add `'l1_tech'` to `account_role` enum. +2. Add `users.can_cover_l1`, `accounts.l1_seats_purchased`, `audit_logs.acting_as`. +3. Extend `flow_proposals` with new columns + backfill existing rows to `source='manual_draft'`. +4. Create `internal_tickets` + RLS policies (account-scoped, WITH CHECK). +5. Create `kb_connector_configs` + RLS policies. +6. Create `kb_documents` + `kb_document_chunks` tables + RLS policies + pgvector index on chunks. + +Per Lesson on tenant-isolated tables: any service-construction site that creates rows on these tables must pass `account_id=` explicitly. Grep all `Model(` sites before merge. + +--- + +## 6. Backend services & endpoints + +### 6.1 New services + +| Module | Purpose | +|---|---| +| `services/match_or_build.py` | Orchestrator. Single async entrypoint `match_or_build(account_id, problem_text, ticket_ref) -> MatchOrBuildResult`. | +| `services/ai_tree_builder.py` | Real-time AI tree generation. Anthropic via existing `_call_anthropic_cached` pattern. Model tier via `settings.get_model_for_action('l1_realtime_build')`. Output validated against the flow node schema with Pydantic; rejects malformed output. | +| `services/kb_connectors/base.py` | Abstract `KBConnector` with `test_credentials`, `list_documents`, `fetch_content`, `subscribe_to_changes` (optional). | +| `services/kb_connectors/itglue.py` | IT Glue REST client. | +| `services/kb_connectors/hudu.py` | Hudu REST client. | +| `services/kb_connectors/microsoft_graph.py` | Microsoft Graph (SharePoint/OneDrive) client. | +| `services/kb_connectors/registry.py` | `KBConnectorRegistry` (mirrors `PsaProviderRegistry`). | +| `services/kb_connectors/encryption.py` | Fernet wrapper (or reuse the PSA one if generic). | +| `services/kb_ingestion_writer.py` | Shared writer: chunk → embed → upsert. Used by manual upload AND connector sync. | +| `services/kb_ingestion_scheduler.py` | APScheduler interval job, `max_instances=1`. Sequential per account; concurrency cap = 4 accounts simultaneously. | +| `services/internal_ticket_service.py` | CRUD + status transitions for `internal_tickets`. | +| `services/l1_session_service.py` | Walking-session lifecycle: start, step, resolve, escalate. Bridges `ai_sessions` and the walked target. | + +### 6.2 Extended services + +- `services/escalation_package_generator.py` — adds inputs: `walked_path`, `ai_draft_proposal_id`, `kb_citations`. New caller path from `l1_session_service.escalate(...)`. +- KB Accelerator endpoint — accepts ingested content via the shared `kb_ingestion_writer`. Manual upload and connector sync share the same persistence path. + +### 6.3 New endpoints + +All under `require_l1_or_coverage` unless noted. Mounted under `/api/v1/l1`. + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| GET | `/l1/queue` | Merged ticket queue (PSA + internal). Pagination + status filter. | `require_l1_or_coverage` | +| POST | `/l1/intake` | Walk-in intake. Body `{problem_statement, customer_name?, customer_contact?}`. Creates ticket, returns `{session_id, target_kind, target_id, intake_type}`. | `require_l1_or_coverage` | +| POST | `/l1/tickets/{ticket_ref}/start` | Start walker from an existing ticket. Internally same as intake but skips ticket creation. | `require_l1_or_coverage` | +| POST | `/l1/sessions/{id}/step` | Record an answer. Body `{node_id, answer, note?}`. Appends to `walked_path_snapshot`. | `require_l1_or_coverage` | +| POST | `/l1/sessions/{id}/resolve` | Close as resolved. Body `{resolution_notes, helpful: bool}`. Sets `validated_by_outcome=true` on the proposal when `helpful=true` AND target was a proposal. Closes the ticket. | `require_l1_or_coverage` | +| POST | `/l1/sessions/{id}/escalate` | Generate escalation package + reassign ticket. Body `{reason, reason_category}`. | `require_l1_or_coverage` | +| GET | `/l1/drafts` | List current user's AI drafts with promotion status. | `require_l1_or_coverage` | + +KB connector endpoints (`/api/v1/kb-connectors`): + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| GET | `/kb-connectors` | List configured connectors for account. | `require_l1_or_above` | +| POST | `/kb-connectors` | Create. OAuth handoff for Microsoft Graph; API token entry for IT Glue/Hudu. | `require_account_owner` | +| DELETE | `/kb-connectors/{id}` | Remove (soft-disable). | `require_account_owner` | +| POST | `/kb-connectors/{id}/sync` | Trigger immediate sync (enqueued). | `require_account_owner` | +| GET | `/kb-connectors/{id}/status` | Sync status + doc count + last error. | `require_l1_or_above` | + +Internal ticket endpoints (`/api/v1/internal-tickets`): + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| GET | `/internal-tickets` | List (account-scoped). | `require_l1_or_coverage` | +| GET | `/internal-tickets/{id}` | Detail. | `require_l1_or_coverage` | +| POST | `/internal-tickets/{id}/promote-to-psa` | Push to configured PSA, set `psa_promoted_ticket_id`. | `require_account_owner` | + +User management addition: + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| PATCH | `/users/{id}/coverage` | Set `can_cover_l1` flag. Body `{can_cover_l1: bool}`. | `require_account_owner` | + +--- + +## 7. Frontend surface + +### 7.1 Sidebar — L1 view + +``` +LOGO +───────────── +Workspace /l1 +Tickets /l1/tickets +My Drafts /l1/drafts +───────────── +Guides /guides +Account /account (filtered — no integrations, no categories) +``` + +No `/pilot`, no `/trees`, no `/flows`, no `/review-queue`, no `/escalations`, no team analytics. Sidebar.tsx picks the nav array by role. + +### 7.2 Sidebar — engineer coverage view + +Engineer's existing sidebar plus a single appended entry "L1 Workspace" → `/l1`. Shown when `canCoverL1 || isOwner || isSuperAdmin`. + +### 7.3 `/l1` dashboard layout + +Three vertical zones, single column, max width ~1100px: + +1. **Greeting** — uppercase tracking date label + Bricolage 700 hero ("Good morning, {firstName}.") +2. **Describe the problem** card — large textarea (autofocus on load), optional `customer_name` + `customer_contact` fields, single primary CTA "Start walk →" (the only electric-blue element on the page) +3. **Open tickets** — section label, count, table rows (merged PSA + internal with origin badges), row hover `bg-elevated` +4. **Resume in progress** — shown only when L1 has a half-walked session + +Tailwind v4 tokens: `bg-page` base, `bg-card` zones, `bg-elevated` row hover, electric-blue accent only on primary CTA. No `text-secondary`. All borders `border-default`. + +### 7.4 `/l1/walk/{sessionId}` walker + +Sticky header + two-pane body, full-height (flex chain per Lesson — every ancestor needs `flex` + `flex-1` + `min-h-0`). + +**Header:** +- Back arrow + ticket ref + customer name + AI-built badge (when target is proposal) +- Problem statement line +- Persistent action buttons: `[ Escalate ]` `[ Resolve ✓ ]` + +**Left pane (main):** +- "Step N · estimated M" label +- Current node card — large yes/no/answer buttons (min 44px tap target) +- Optional note textarea below the card (appended to `walked_path_snapshot`) +- On a fresh proposal that's still building: shimmer placeholder + "Building from KB… ~10s" + +**Right pane (transcript):** +- Walked-so-far list (node title + answer chosen) +- Current step highlight +- "Source:" section listing KB citations for the current node (proposal walks only) + +**Resolve modal:** +- "Did this resolve it?" `[ Yes ]` `[ No ]` +- Resolution notes textarea +- Yes + target was proposal → sets `validated_by_outcome=true` +- No → prompt to escalate instead + +**Escalate modal:** +- Reason category dropdown: *Out of L1 scope · Customer demanding senior · Tree dead-ended · AI tree wrong · Other* +- Free-text reason +- Confirm + +### 7.5 `/l1/drafts` page + +Read-only list, columns: `created` · `problem (truncated)` · `ticket #` · `status` (pending review / outcome-validated / promoted / retired). Click → read-only detail view showing tree + walked path. No edit affordances. + +### 7.6 `/l1/tickets` page + +Full-page version of the dashboard queue widget. Filter by status, origin (PSA/internal), assigned-to-me. + +### 7.7 Coverage banner + +`` — slim ~32px band, info-cyan-dim background, mounted at the top of all `/l1/*` pages when `!isL1Tech && (canCoverL1 || isOwner || isSuperAdmin)`: + +``` +You're covering L1. Actions logged as coverage. [Switch back →] +``` + +The "Switch back" link returns to `/`. + +### 7.8 Routing + +```tsx +const L1Dashboard = lazyWithRetry(() => import('@/pages/l1/L1Dashboard')) +const L1WalkPage = lazyWithRetry(() => import('@/pages/l1/L1WalkPage')) +const L1DraftsPage = lazyWithRetry(() => import('@/pages/l1/L1DraftsPage')) +const L1TicketsPage = lazyWithRetry(() => import('@/pages/l1/L1TicketsPage')) +``` + +Mounted under the `/` ProtectedRoute branch at: +- `/l1` → `L1Dashboard` +- `/l1/walk/:sessionId` → `L1WalkPage` +- `/l1/drafts` → `L1DraftsPage` +- `/l1/tickets` → `L1TicketsPage` + +Wrapped in `L1RouteGuard` (403 if not `l1_tech` AND not coverage-flagged). `ProtectedRoute.tsx` post-login redirect: L1 users land on `/l1` instead of `/`. + +`lazyWithRetry`, not `React.lazy` (per existing convention). + +--- + +## 8. AI match-or-build pipeline + +### 8.1 Match-or-build algorithm + +``` +match_or_build(account_id, problem_text, ticket_ref): + embedding = embedding_service.embed(problem_text) + + # 1. Match authored flows + flow_hits = rag_service.match_flows(account_id, embedding, k=5) + if flow_hits and flow_hits[0].score >= MATCH_THRESHOLD: + return {kind: 'flow', id: flow_hits[0].flow_id, score: ...} + + # 2. Match outcome-validated proposals only + proposal_hits = rag_service.match_proposals( + account_id, embedding, k=5, + where=validated_by_outcome=true, + ) + if proposal_hits and proposal_hits[0].score >= MATCH_THRESHOLD: + return {kind: 'proposal', id: proposal_hits[0].proposal_id, score: ...} + + # 3. Build fresh + kb_chunks = rag_service.match_kb_chunks(account_id, embedding, k=8) + if not kb_chunks: + raise BuildAbortedNoKB( + "Cannot build a tree with no KB content. " + "Upload docs or wait for a connector sync." + ) + nearest_flows = flow_hits[:3] + proposal = ai_tree_builder.build( + problem_text, kb_chunks, nearest_flows, account_id, ticket_ref + ) + return {kind: 'proposal', id: proposal.id, score: None} +``` + +`MATCH_THRESHOLD` — per-account configurable; default `0.75` (cosine). + +The "no empty KB build" rule is enforced because an AI tree built on the model's general knowledge — without MSP-specific grounding — risks suggesting unsafe or hallucinated fixes. + +### 8.2 AI tree-build details + +**Model:** `settings.get_model_for_action('l1_realtime_build')`. Recommend Sonnet for v1 (latency-sensitive). + +**Schema:** output validated against the existing flow node schema (matches `tree_editor` output). Validation failure aborts the build rather than persisting malformed data. + +**Prompt strategy** (per Lesson on prompt anti-parrot — critical): +- System prompt: role definition + output schema using `` notation only. Never literal field values. +- Few-shot examples loaded as user/assistant messages from a separate file, never inline in the system prompt. +- User message: `{problem_statement}` + `{kb_context: [doc_title, section, content]}` + `{nearest_flow_summaries}` + instruction to cite KB chunks per node. +- Output includes `kb_citations: [{node_id, kb_doc_id, snippet}]` for walker's "Source:" pane and engineer review. + +**Latency:** whole-tree-then-return (~5–15s typical). UX is a shimmer "Building from KB…" placeholder. Streaming node-by-node deferred to v2. + +**Anthropic SDK config** (per Lesson): `max_retries=1`. Prompt caching enabled on the stable system+few-shot bundle (high cache hit rate expected per account). + +**Telemetry:** +- `l1.match_or_build.duration_ms`, `l1.match_or_build.outcome` (`flow_match`/`proposal_match`/`built`/`aborted_no_kb`) +- `anthropic.cache` events (existing pattern) tagged `action=l1_realtime_build` +- `l1.tree_build.tokens_in`, `tokens_out` + +**Anti-parrot guardrail:** the existing `tests/test_prompt_anti_parrot.py` auto-discovers new prompt constants via pattern match on `*_PROMPT` / `*_SCHEMA` / `*_PROTOCOL` / `*_FORMAT`. No new test required. + +### 8.3 Hallucinated-citation defense + +After build, the writer verifies every `kb_doc_id` in `kb_citations` exists in the account's KB. Unverified citations are stripped from the walker's "Source:" pane (the node still renders, just without a source). Engineer review surfaces stripped citations as a warning. + +--- + +## 9. KB ingestion + +### 9.1 Connector interface + +```python +class KBConnector(ABC): + async def test_credentials(self) -> bool + async def list_documents(self, since: datetime | None) -> AsyncIterator[KBDocRef] + async def fetch_content(self, ref: KBDocRef) -> KBDocContent + async def subscribe_to_changes(self) -> AsyncIterator[ChangeEvent] # optional, no-op v1 +``` + +Registry dispatches by `provider` string. Credentials encrypted at rest via Fernet (reuse `services/psa/encryption.py` pattern). + +### 9.2 Per-connector specifics + +| | IT Glue | Hudu | Microsoft Graph (SharePoint/OneDrive) | +|---|---|---|---| +| Auth | API token (header) | API key (header) | OAuth 2.0 | +| Ingested types | Documents, KB Articles | Articles | docx, pdf, md, txt | +| Never ingested | Passwords, Configurations, sensitive flex assets | Passwords, sensitive items | Files in folders matching `(secret\|confidential\|private)` heuristic; files with a tenant Sensitivity Label | +| Filtering | Per-org (techs see all client orgs they have permission to) | Per-folder | Per-site / per-drive (owner picks at config time) | +| Rate limits | ~100/min token bucket | ~250/min token bucket | Built-in Graph throttling backoff | + +All three deliver content to `kb_ingestion_writer` which: +1. Chunks (paragraph-aware, configurable size with overlap) +2. Embeds via `embedding_service` +3. Upserts into `kb_documents` keyed on `(connector_config_id, source_ref)`; chunks into `kb_document_chunks` + +Cross-connector conflicts: same doc text appearing in two connectors yields two rows (provider-scoped `source_ref`). Engineers can dedup manually if needed. + +### 9.3 Sync scheduling + +`kb_ingestion_scheduler.py` runs as APScheduler interval job, `max_instances=1`. Per cycle: +1. Query active `kb_connector_configs` where `last_sync_at` is older than `sync_interval_minutes` (default 360 = 6h). +2. Dispatch per account; concurrency cap = 4 simultaneous accounts. +3. For each connector: `list_documents(since=last_sync_at)` → for each ref, `fetch_content` → write. +4. Compute the diff between current refs and existing rows (same `connector_config_id`); soft-delete missing ones via `deleted_at`. +5. Update `last_sync_at`, `last_sync_status`, `last_sync_error`. + +Must use `_admin_session_factory()` not `get_db()` for startup-side and scheduler-side queries (per Lesson on RLS at startup — no `app.current_account_id` set). + +Immediate sync via `POST /api/v1/kb-connectors/{id}/sync` enqueues a job; scheduler picks it up within ~30s. + +--- + +## 10. Escalation flow + +1. L1 clicks **Escalate** → modal (reason category + optional free text). +2. `POST /api/v1/l1/sessions/{id}/escalate` → backend: + - Calls extended `escalation_package_generator.generate(session_id, include_l1_walk=true)`. Package contents: + ``` + problem_statement, customer_name, customer_contact, + ticket_ref (PSA id or internal id), + target_kind ('flow' | 'proposal'), target_id, + walked_path, + ai_draft_proposal_id, + kb_citations, + escalation_reason, reason_category, l1_user_id + ``` + - Creates an `ai_session` with the package serialized into system context for the chat surface. + - If PSA-backed: `psa_provider.reassign_ticket(ticket_id, to=account.engineer_queue_name)`. Default `'Tier 2'`. Owner configurable in `/account/integrations`. + - If internal-backed: `internal_tickets.status='escalated'`, `assigned_user_id=null` (round-robin assignment is out of scope). + - Writes notification via existing `notification_service` — bell badge to all engineers in account. + - Audit log entry; `acting_as` reflects whether L1 or coverage-engineer escalated. +3. Toast on L1 side, return to `/l1`. +4. Engineer clicks notification → `/pilot/{sessionId}` → chat surface renders the package as a sticky "Escalation context" card; engineer continues in chat. + +**Un-escalate is out of scope.** If engineer wants to bounce back, they reassign in PSA manually. + +--- + +## 11. Internal ticket fallback + +When the account has no active PSA provider: +- Intake creates `internal_tickets` row instead of a PSA ticket. +- Queue surface merges PSA + internal with `Internal` / `PSA` origin badge. +- Escalation flips `internal_tickets.status='escalated'` and assigns engineer (or leaves null for any engineer to claim — v1 behavior). +- Engineer post-escalation sees the internal ticket as a session; no PSA roundtrip. + +**Promote to PSA:** owner-only action on any internal ticket. Pushes the ticket into the configured PSA provider, sets `psa_promoted_ticket_id`. Manual; not automatic on PSA-install. Lets MSPs adopt PSA mid-flight without orphaning prior internal tickets. + +--- + +## 12. Outcome-validation lifecycle + +``` +1. L1 intake → match_or_build → FlowProposal(source='ai_realtime_l1', + validated_by_outcome=false, + linked_ticket_id=...) +2. L1 walks → POST /l1/sessions/{id}/step appends to walked_path_snapshot +3. L1 hits Resolve: + modal: "Did this resolve it?" [Yes] [No] + resolution_notes +4. helpful=true → flow_proposal.validated_by_outcome = true + → walked_path_snapshot frozen + → ticket closed (PSA or internal) + helpful=false → validated_by_outcome stays false + → L1 prompted: "Escalate instead?" +5. Engineer review queue: + ORDER BY validated_by_outcome DESC, created_at DESC + - Outcome-validated drafts surface first + - Promote / edit-and-promote / retire +6. Promote → new flow with source='ai_promoted'; original proposal kept with status='promoted' + → future match_or_build matches the new flow on the flow-match pass +``` + +--- + +## 13. Out of scope (v1 non-goals) + +- End-user / self-service portal ("L0" tier). +- Engineer warm-transfer / live take-over during a call. +- L1 ↔ engineer real-time chat during a call. +- Multi-language UI / customer-language toggle in walker. +- Auto-promote internal tickets to PSA on integration install. +- AI tree streaming (node-by-node). +- KB write-back to IT Glue/Hudu/SharePoint (read-only ingestion). +- Confluence connector. +- Per-step KB citation editing in engineer review (engineers edit the tree, not citations). +- Final Stripe pricing SKU (data model supports differential pricing; price set in Stripe dashboard). +- "Switch to L1 mode" persistent toggle for engineers (coverage flag + banner only). +- Cancel/un-escalate flow. +- Round-robin engineer assignment on internal-ticket escalations. + +--- + +## 14. Testing strategy + +### 14.1 Backend (pytest) + +- Unit: `match_or_build` covers all four paths (flow-match, proposal-match, built, aborted_no_kb). +- Unit: `ai_tree_builder` schema validation — assert rejection of malformed Anthropic output before persistence. +- Unit: each connector's `list_documents` + `fetch_content` against recorded HTTP fixtures. +- Integration: intake → walk → resolve(helpful=true) → assert `FlowProposal.validated_by_outcome=true`, ticket closed. +- Integration: intake → walk → escalate → assert PSA `reassign_ticket` invoked, `ai_session` created with package, audit log entry, notification dispatched. +- Integration: KB scheduler — `max_instances=1`, sequential per-account, soft-delete on removal. +- **RLS regression** (highest priority): `l1_tech` user in account A cannot read account B's tickets, drafts, KB docs, or connector configs. Added to existing RLS test suite. +- Anti-parrot: existing CI test auto-discovers new prompt module. + +### 14.2 Frontend + +- Unit: `usePermissions` — L1 sees L1 paths, blocked from engineer paths. Coverage flag opens L1 paths. +- Unit: `L1WalkPage` — node advance, escalate modal, resolve modal flips `validated_by_outcome` correctly. +- Unit: `L1CoverageBanner` — visible for engineer-with-flag on `/l1/*`, hidden for L1 users. +- E2E (Playwright, scoped selectors per Lesson): + - L1 sign-in → dashboard → intake → walker → resolve → verify ticket closed + proposal flagged. + - Engineer with `can_cover_l1` → sidebar entry visible → click → coverage banner shows → walks a session → audit log records `acting_as='l1_coverage'`. + - L1 hitting `/pilot`, `/trees/new`, `/escalations` → 403 or redirect. + +--- + +## 15. Acceptance criteria (v1 ships when…) + +- L1 role assignable; assigned L1 sees L1 sidebar only; no engineer route reachable. +- L1 intake creates a ticket (PSA or internal) and lands in walker session. +- Walker handles both flows and proposals; AI-built badge + sources shown for proposals. +- Escalate generates package, reassigns ticket, notifies engineers. +- Resolve flips `validated_by_outcome`; review queue prioritizes outcome-validated drafts. +- All three KB connectors configurable; initial sync + periodic re-sync + soft-delete on removal. +- AI build refuses with informative error when account KB is empty. +- Coverage flag works end-to-end with audit-log tagging. +- RLS blocks cross-tenant reads on every new table. +- L1 seat count tracked separately from engineer seats in admin/billing UI. + +--- + +## 16. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| AI builds an unsafe tree | Schema validation rejects malformed output. Engineer review is the gate before draft becomes "real" flow. v1 refuses to build when KB is empty. | +| Hallucinated KB citations | Post-build verification that each `kb_doc_id` exists; unverified citations stripped from walker, surfaced as warning in engineer review. | +| Duplicate proposals for same problem | Validated-proposal match pass deduplicates after one L1 validates; pre-validation dups are tolerated and dedup'd during engineer review. | +| KB ingestion captures sensitive content | Per-connector deny-lists (passwords, sensitive flex assets, MS Graph Sensitivity Labels). Owners exclude specific folders/sites at config. All ingested docs visible in `/account/kb` for manual deletion. | +| AI build latency frustrates customer on call | Build-progress UI sets expectation. Escalate button visible from page load. Future: pre-warm builds on PSA-ticket-landed event. | +| Three connectors is more scope than originally proposed | Acknowledged. Each connector is ~1–2 weeks of work. Plan should sequence them and allow shipping with IT Glue + Hudu first if SharePoint slips. | +| Engineer review queue backlog stalls library growth | Validated-proposal match pass means good drafts get reused without engineer review. Backlog only delays the move from `'proposal'` to `'flow'`, not the L1's ability to use validated content. | + +--- + +## 17. Naming reference + +| Layer | Value | +|---|---| +| DB enum (`account_role`) | `l1_tech` | +| UI display | "L1 Tech" / "L1" | +| Sidebar entry | "L1 Workspace" | +| URL prefix | `/l1` | +| Coverage flag column | `users.can_cover_l1` | +| Coverage audit tag | `acting_as = 'l1_coverage'` | +| Pricing label | "L1 seat" | +| Stripe SKU | Set in Stripe dashboard at launch — data model supports differential pricing now | + +--- + +## 18. Open implementation decisions (deferred to plan, not blocking design) + +- Specific `MATCH_THRESHOLD` default value validation (initial 0.75, tune from telemetry post-launch). +- Specific Anthropic model choice for `l1_realtime_build` (Sonnet vs Opus — pick based on quality benchmark during plan). +- Chunk size + overlap for KB ingestion writer (tune in implementation). +- Engineer queue label default (`'Tier 2'` vs `'Engineering'`) — owner-configurable anyway. +- Exact look of the build-progress shimmer animation — design-system handoff. + +These are tuning/UX-polish details, not architectural forks. They land during the writing-plans phase, not here. + +### Note on scope and phasing + +This is a substantive feature: new role, four frontend pages, ~12 endpoints, AI tree-builder, three KB connectors, escalation extensions, and six migrations. The implementation plan will almost certainly phase the work — a reasonable cut is: + +- **Phase 1:** role + L1 surface against existing authored flows (no AI build, no connectors yet). Validates the seat model, walker UX, escalation, internal ticket fallback, and coverage flag end-to-end. +- **Phase 2:** `kb_documents` schema + AI tree-builder + match-or-build pipeline. Enables real-time AI flows grounded on manually-uploaded KB. +- **Phase 3:** the three KB connectors (IT Glue, Hudu, SharePoint/OneDrive). Each is roughly self-contained — can ship one at a time and reorder if a connector blocks. + +Phasing is a plan-level decision; the spec captures the full feature. + +--- + +*End of spec.* From 07a29f630a1d2bd2d9fc2c63ce056fbd5fbe30d9 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 28 May 2026 10:51:57 -0400 Subject: [PATCH 03/42] docs(design): revise L1 spec after review (sessions, adhoc, OAuth, seat enforcement) Restructure walked_path off FlowProposal onto new l1_walk_sessions table (each L1 walk has its own path; proposal carries only the validation bit). Add adhoc walk variant for live calls when no KB content exists, with a dedicated BuildAbortedNoKB screen offering ad-hoc/escalate/near-miss options. Introduce SUGGEST_THRESHOLD below MATCH_THRESHOLD so near-miss flows surface as suggestions instead of triggering a 10s build. Define empty-state dashboard mode for first-run accounts. Spec the Microsoft Graph OAuth flow concretely (multi-tenant app, redirect callback, token refresh). Add seat enforcement for both L1 and engineer tracks via shared helper (engineer enforcement was missing in current code). Make audit policy explicit (resolve/escalate only, not per-step). Add session lifecycle (concurrent sessions, browser-close recovery, 24h abandonment). Clarify KB doc visibility is owner/engineer only (L1s see citations in walker, not /account/kb directly). Acknowledge escalation notification noise as v1 limitation with targeted notification deferred to v2. Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-05-28-l1-workspace-design.md | 432 +++++++++++++++--- 1 file changed, 374 insertions(+), 58 deletions(-) diff --git a/docs/superpowers/specs/2026-05-28-l1-workspace-design.md b/docs/superpowers/specs/2026-05-28-l1-workspace-design.md index 6775ef7b..d9526252 100644 --- a/docs/superpowers/specs/2026-05-28-l1-workspace-design.md +++ b/docs/superpowers/specs/2026-05-28-l1-workspace-design.md @@ -66,6 +66,31 @@ This mirrors the existing orthogonal-flag pattern (`is_team_admin`) — no new a - Existing `accounts.seats_purchased` continues to represent engineer seats - New Stripe SKU placeholder for L1 seat; actual pricing set in Stripe dashboard out-of-band +### 3.6 Seat enforcement (L1 + engineer together) + +**Important context surfaced during spec review:** there is currently **no** seat-limit enforcement in the codebase. `subscription.seat_limit` is stored from Stripe webhook payloads and surfaced in API responses, but no endpoint blocks invites when the limit is reached. To avoid shipping L1 with enforcement while engineer seats remain unbounded (inconsistent SKU story), this spec adds enforcement for both tracks as part of v1. + +**Shared helper:** `services/seat_enforcement.py`: +```python +def check_seat_available( + account: Account, + subscription: Subscription, + role: Literal['engineer', 'l1_tech'], + db: AsyncSession, +) -> SeatCheckResult +``` +Counts active users in the account at the given role, compares against the subscription's role-specific limit (`seat_limit` for engineer, `l1_seat_limit` for L1). Returns `{available: bool, current: int, limit: int}`. + +**Enforcement points:** +- `POST /api/v1/invites` (invite create) — blocks with `402 Payment Required` (or `422` with code `seat_limit_exceeded`) when the target role's seats are full. Body: `{current, limit, role, upgrade_url: }`. +- Invite accept (`/api/v1/accept-invite`) — re-checks at acceptance time (race-condition guard). +- Role change on existing user (e.g., promoting `viewer` to `engineer`) — same check before commit. +- Admin "assign role" UI — pre-checks seat availability and disables the option when full. + +**Grandfathering:** any account currently over-seated (existing inviting beyond the limit was technically allowed before) is **not** retroactively kicked. The enforcement applies from migration-time forward — existing over-seated accounts get a banner prompting upgrade or seat removal but functionality is preserved until they invite a new user. + +**Frontend:** `/admin/users` and `/account/users` show a seat counter widget for each role (`3 / 5 engineer seats used · 2 / 5 L1 seats used`). When a count exceeds the limit, the widget renders amber with a tooltip explaining grandfathering. + --- ## 4. Architecture overview @@ -73,11 +98,14 @@ This mirrors the existing orthogonal-flag pattern (`is_team_admin`) — no new a ### 4.1 New components **Frontend:** -- `pages/l1/L1Dashboard.tsx` — landing page; ticket queue + describe-the-problem intake -- `pages/l1/L1WalkPage.tsx` — purpose-built walker; yes/no cards, transcript, persistent escalate/resolve -- `pages/l1/L1DraftsPage.tsx` — read-only list of the L1's AI drafts and promotion status -- `pages/l1/L1TicketsPage.tsx` — full-page queue (PSA + internal merged) -- `components/l1/L1CoverageBanner.tsx` — slim banner shown to engineer-coverers +- `pages/l1/L1Dashboard.tsx` — landing page; ticket queue + describe-the-problem intake. Two modes (empty-state + active). +- `pages/l1/L1WalkPage.tsx` — purpose-built walker with two internal variants: **tree** (flow/proposal) and **adhoc** (note-taking). +- `pages/l1/L1NoKBScreen.tsx` — BuildAbortedNoKB screen with three CTAs (adhoc / escalate / use near-miss). +- `pages/l1/L1DraftsPage.tsx` — read-only list of the L1's AI drafts and promotion status. +- `pages/l1/L1TicketsPage.tsx` — full-page queue (PSA + internal merged). +- `components/l1/L1CoverageBanner.tsx` — slim banner shown to engineer-coverers. +- `components/l1/SuggestPrompt.tsx` — inline near-miss suggestion ("Use this flow / Build new"). +- `components/admin/SeatCounterWidget.tsx` — engineer + L1 seat usage counts on `/admin/users` and `/account/users`. **Backend:** - `services/match_or_build.py` — orchestrator (RAG match → fallback to AI build) @@ -95,6 +123,7 @@ This mirrors the existing orthogonal-flag pattern (`is_team_admin`) — no new a - `services/flow_matching_engine.py` — existing - `services/escalation_package_generator.py` — extended to include walked path, AI draft pointer, KB citations - `models/FlowProposal` — new columns (see §5) +- New `models/L1WalkSession` — per-session state for tree walks and adhoc walks (see §5.3) - `services/psa/` — already supports ticket create + reassign across CW/Autotask/HaloPSA - `services/embedding_service.py` — used by KB ingestion writer - New `kb_documents` + `kb_document_chunks` tables for RAG-retrievable document storage, separate from the existing `kb_imports` (which is a document→tree conversion record, not a persistent KB store — see §5) @@ -143,8 +172,9 @@ Existing AI-draft model. Add columns: | `source` | `VARCHAR(30) NOT NULL` | `'ai_realtime_l1' \| 'kb_accelerator' \| 'manual_draft'`. Backfill existing rows to `'manual_draft'`. | | `linked_ticket_id` | `VARCHAR(64) NULL` | PSA id or internal_tickets UUID (stored as text) | | `linked_ticket_kind` | `VARCHAR(10) NULL` | `'psa' \| 'internal'` | -| `validated_by_outcome` | `BOOLEAN NOT NULL DEFAULT FALSE` | Flipped to true when L1 resolves and marks helpful=true | -| `walked_path_snapshot` | `JSONB NULL` | Frozen at resolve/escalate; shape `[{node_id, question, answer, l1_note}]` | +| `validated_by_outcome` | `BOOLEAN NOT NULL DEFAULT FALSE` | Flipped to true when any L1 walks this proposal to a helpful resolve | + +> **Note (revised after spec review):** the walked path lives on the **session** (`l1_walk_sessions`, §5.3), not the proposal. A single proposal may be walked by multiple L1s over time — each walk has its own path. The proposal carries only the boolean validation signal; engineer review queries the latest validated session's path for context. Engineer review queue sort: ```sql @@ -174,7 +204,42 @@ resolved_at TIMESTAMPTZ NULL RLS: account-scoped, WITH CHECK on insert/update. -### 5.3 `kb_connector_configs` — new +### 5.3 `l1_walk_sessions` — new + +Per-session state for an L1 walking a ticket. Supports three session kinds: walking an authored flow, walking an AI-built proposal, or an **adhoc walk** with no tree (used when no KB content exists and the L1 needs to handle the call manually but still wants the session/ticket/escalation framework). + +``` +id UUID PRIMARY KEY +account_id UUID NOT NULL (RLS-scoped) +created_by_user_id UUID NOT NULL (the L1, or coverage engineer) +acting_as VARCHAR(30) NULL -- 'l1_coverage' when engineer covers; null for native L1 +ticket_id VARCHAR(64) NOT NULL -- PSA id or internal_tickets UUID as text +ticket_kind VARCHAR(10) NOT NULL -- 'psa' | 'internal' +session_kind VARCHAR(20) NOT NULL -- 'flow' | 'proposal' | 'adhoc' +flow_id UUID NULL FK trees +flow_proposal_id UUID NULL FK flow_proposals +current_node_id VARCHAR(100) NULL -- node within the tree; null for adhoc +walked_path JSONB NOT NULL DEFAULT '[]'::jsonb -- [{node_id, question, answer, l1_note}]; [] for adhoc +walk_notes JSONB NOT NULL DEFAULT '[]'::jsonb -- free-form notes (adhoc) or supplementary notes (tree walks) +status VARCHAR(20) NOT NULL DEFAULT 'active' -- 'active' | 'resolved' | 'escalated' | 'abandoned' +resolution_notes TEXT NULL +helpful BOOLEAN NULL -- the "did this work?" answer at resolve time +escalation_reason TEXT NULL +escalation_reason_category VARCHAR(30) NULL +started_at TIMESTAMPTZ NOT NULL +last_step_at TIMESTAMPTZ NOT NULL +resolved_at TIMESTAMPTZ NULL +``` + +**Constraints:** +- `CHECK (session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) OR (session_kind = 'adhoc' AND flow_id IS NULL AND flow_proposal_id IS NULL)` +- Soft "abandoned" status: if `last_step_at` is older than 24h and status is still `'active'`, a cleanup task flips it to `'abandoned'` (preserves data; just gets it off the L1's "Resume in progress" widget). + +RLS: account-scoped, WITH CHECK on insert/update. + +**Why a new table (rather than reusing `ai_sessions`):** `ai_sessions` is the chat-conversation model — flat message list, no node-state, no flow/proposal linkage. An L1 walk has different state (current node, walked path, walk-kind constraint). Forcing it into `ai_sessions` would require multiple new nullable columns on a heavily-used model and overload its semantics. Separate table = cleaner separation and lower regression risk. + +### 5.4 `kb_connector_configs` — new ``` id UUID PRIMARY KEY @@ -195,7 +260,7 @@ UNIQUE (account_id, provider, display_name) RLS: account-scoped, WITH CHECK. -### 5.4 New tables: `kb_documents` + `kb_document_chunks` +### 5.5 New tables: `kb_documents` + `kb_document_chunks` The existing `kb_imports` table is a document→tree conversion record (status lifecycle `processing | ready | committed | failed`, target `tree_id`) — designed to turn one document into one authored flow. It is NOT a persistent KB document store and does not power RAG retrieval. @@ -241,23 +306,36 @@ RLS on both tables: account-scoped, WITH CHECK on insert. **Coexistence with `kb_imports`:** when an L1 (or owner) uploads a doc, the system can populate **both** — the existing KBImport pipeline produces a draft tree, and the new ingestion writer additionally chunks+embeds the doc into `kb_documents` for RAG. Both paths share the upload endpoint but write to independent tables. Connectors only write to `kb_documents` (no auto-tree-conversion from synced docs in v1). -### 5.5 Other column additions +### 5.6 Other column additions - `users.can_cover_l1 BOOLEAN NOT NULL DEFAULT FALSE` - `accounts.l1_seats_purchased INTEGER NOT NULL DEFAULT 0` - `audit_logs.acting_as VARCHAR(30) NULL` — `'l1_coverage'` when engineer is in coverage mode; null otherwise - `account_role` enum: add `'l1_tech'` +- `subscriptions.l1_seat_limit INTEGER NULL` (mirrors existing `seat_limit` which is treated as the engineer limit going forward) -### 5.6 Migration ordering +### 5.6.1 Audit log policy (explicit) -Six manual Alembic revisions (no `--rev-id`, no `--autogenerate`): +Audit rows are written **only** at session terminal events — `resolve` and `escalate` — not on each `step`. The walked path is recorded incrementally on `l1_walk_sessions.walked_path` as it accumulates; the audit row at resolve/escalate captures the frozen final snapshot inline. Mid-walk step-by-step audit logging is not v1 because: + +- MSP IT troubleshooting actions taken via an L1 walk are rarely high-stakes enough to justify the row-volume cost (~5–20 audit rows per call vs. 1). +- The `walked_path` on the session is itself the auditable record for the L1's path through the tree; the session table is account-scoped and retained. +- If a customer-impacting incident traces back to an L1 walk, the path is recoverable from the session row even when the session is `abandoned` (cleanup task preserves the row, just flips status). + +If higher granularity is needed later (e.g., for compliance-heavy verticals), it's an additive change: subscribe to step events, emit an audit row per step. Not blocking v1. + +### 5.7 Migration ordering + +Eight manual Alembic revisions (no `--rev-id`, no `--autogenerate`): 1. Add `'l1_tech'` to `account_role` enum. 2. Add `users.can_cover_l1`, `accounts.l1_seats_purchased`, `audit_logs.acting_as`. -3. Extend `flow_proposals` with new columns + backfill existing rows to `source='manual_draft'`. -4. Create `internal_tickets` + RLS policies (account-scoped, WITH CHECK). -5. Create `kb_connector_configs` + RLS policies. -6. Create `kb_documents` + `kb_document_chunks` tables + RLS policies + pgvector index on chunks. +3. Extend `flow_proposals` with new columns + backfill existing rows to `source='manual_draft'`. **Do not** add `walked_path_snapshot` — that column lives on the new sessions table. +4. Create `l1_walk_sessions` + RLS policies (account-scoped, WITH CHECK) + check constraint on session_kind combinations. +5. Create `internal_tickets` + RLS policies. +6. Create `kb_connector_configs` + RLS policies. +7. Create `kb_documents` + `kb_document_chunks` tables + RLS policies + pgvector index on chunks. +8. Add seat-enforcement support: `subscriptions.l1_seat_limit INTEGER NULL` (already have `seat_limit` for engineers — kept as-is and treated as the engineer limit going forward). Per Lesson on tenant-isolated tables: any service-construction site that creates rows on these tables must pass `account_id=` explicitly. Grep all `Model(` sites before merge. @@ -280,7 +358,9 @@ Per Lesson on tenant-isolated tables: any service-construction site that creates | `services/kb_ingestion_writer.py` | Shared writer: chunk → embed → upsert. Used by manual upload AND connector sync. | | `services/kb_ingestion_scheduler.py` | APScheduler interval job, `max_instances=1`. Sequential per account; concurrency cap = 4 accounts simultaneously. | | `services/internal_ticket_service.py` | CRUD + status transitions for `internal_tickets`. | -| `services/l1_session_service.py` | Walking-session lifecycle: start, step, resolve, escalate. Bridges `ai_sessions` and the walked target. | +| `services/l1_session_service.py` | Walking-session lifecycle: start (flow/proposal/adhoc), step, notes, resolve, escalate, escalate-without-walk. Owns `l1_walk_sessions` writes. | +| `services/l1_session_cleanup.py` | APScheduler job (hourly, `max_instances=1`) flipping stale `active` sessions to `abandoned` after 24h of inactivity. | +| `services/seat_enforcement.py` | Shared helper used by invite, accept-invite, and role-change paths. Returns `SeatCheckResult` for engineer + L1 roles consistently. | ### 6.2 Extended services @@ -294,11 +374,14 @@ All under `require_l1_or_coverage` unless noted. Mounted under `/api/v1/l1`. | Method | Path | Purpose | Auth | |---|---|---|---| | GET | `/l1/queue` | Merged ticket queue (PSA + internal). Pagination + status filter. | `require_l1_or_coverage` | -| POST | `/l1/intake` | Walk-in intake. Body `{problem_statement, customer_name?, customer_contact?}`. Creates ticket, returns `{session_id, target_kind, target_id, intake_type}`. | `require_l1_or_coverage` | +| POST | `/l1/intake` | Walk-in intake. Body `{problem_statement, customer_name?, customer_contact?, force_build?}`. Creates ticket, runs `match_or_build`. Response is one of: `{outcome: 'matched', session_id, session_kind, target_id}` · `{outcome: 'suggest', suggestion, can_build}` (frontend prompts user) · `{outcome: 'aborted_no_kb', near_miss?, ticket_ref}` (frontend renders BuildAbortedNoKB screen §8.4). | `require_l1_or_coverage` | | POST | `/l1/tickets/{ticket_ref}/start` | Start walker from an existing ticket. Internally same as intake but skips ticket creation. | `require_l1_or_coverage` | -| POST | `/l1/sessions/{id}/step` | Record an answer. Body `{node_id, answer, note?}`. Appends to `walked_path_snapshot`. | `require_l1_or_coverage` | -| POST | `/l1/sessions/{id}/resolve` | Close as resolved. Body `{resolution_notes, helpful: bool}`. Sets `validated_by_outcome=true` on the proposal when `helpful=true` AND target was a proposal. Closes the ticket. | `require_l1_or_coverage` | +| POST | `/l1/sessions/{id}/step` | Record an answer (tree walks only). Body `{node_id, answer, note?}`. Appends to `l1_walk_sessions.walked_path`. | `require_l1_or_coverage` | +| POST | `/l1/sessions/{id}/notes` | Update walk notes (adhoc walks only). Body `{notes: JSONB}`. Replaces `l1_walk_sessions.walk_notes`. Debounced auto-save from frontend. | `require_l1_or_coverage` | +| POST | `/l1/sessions/{id}/resolve` | Close as resolved. Body `{resolution_notes, helpful: bool}`. Sets `validated_by_outcome=true` on the proposal when `helpful=true` AND `session_kind='proposal'`. Closes the ticket. | `require_l1_or_coverage` | | POST | `/l1/sessions/{id}/escalate` | Generate escalation package + reassign ticket. Body `{reason, reason_category}`. | `require_l1_or_coverage` | +| POST | `/l1/sessions/adhoc` | Start an adhoc walk. Body `{ticket_ref?, ticket_kind?, problem_statement, customer_name?, customer_contact?}`. If `ticket_ref` omitted, creates a ticket first (PSA or internal). Returns `{session_id}`. | `require_l1_or_coverage` | +| POST | `/l1/escalate-without-walk` | Escalate immediately without a walking session (used from the BuildAbortedNoKB screen). Body `{problem_statement, customer_name?, customer_contact?, reason_category}`. Creates ticket + escalated `l1_walk_sessions` row + escalation package. | `require_l1_or_coverage` | | GET | `/l1/drafts` | List current user's AI drafts with promotion status. | `require_l1_or_coverage` | KB connector endpoints (`/api/v1/kb-connectors`): @@ -319,11 +402,17 @@ Internal ticket endpoints (`/api/v1/internal-tickets`): | GET | `/internal-tickets/{id}` | Detail. | `require_l1_or_coverage` | | POST | `/internal-tickets/{id}/promote-to-psa` | Push to configured PSA, set `psa_promoted_ticket_id`. | `require_account_owner` | -User management addition: +User management additions: | Method | Path | Purpose | Auth | |---|---|---|---| | PATCH | `/users/{id}/coverage` | Set `can_cover_l1` flag. Body `{can_cover_l1: bool}`. | `require_account_owner` | +| GET | `/accounts/me/seats` | Returns seat usage `{engineer: {current, limit}, l1_tech: {current, limit}}`. Used by admin/users UIs to render the counter widget. | `require_engineer_or_admin` | + +Seat-enforcement integration points (no new endpoints — enforcement is inserted into existing flows): +- `POST /api/v1/invites` (invite create) — returns `402 Payment Required` (or `422` with `code: seat_limit_exceeded`) when target role has no remaining seats. Body includes `{current, limit, role, upgrade_url}`. +- `POST /api/v1/accept-invite` — race-condition re-check at acceptance time. +- Role-change endpoints — same check. --- @@ -350,28 +439,73 @@ Engineer's existing sidebar plus a single appended entry "L1 Workspace" → `/l1 ### 7.3 `/l1` dashboard layout -Three vertical zones, single column, max width ~1100px: +The dashboard has two modes determined on load: **empty-state** (account has no flows AND no KB documents) or **active** (normal state). + +**Active mode** — four vertical zones, single column, max width ~1100px: 1. **Greeting** — uppercase tracking date label + Bricolage 700 hero ("Good morning, {firstName}.") 2. **Describe the problem** card — large textarea (autofocus on load), optional `customer_name` + `customer_contact` fields, single primary CTA "Start walk →" (the only electric-blue element on the page) 3. **Open tickets** — section label, count, table rows (merged PSA + internal with origin badges), row hover `bg-elevated` -4. **Resume in progress** — shown only when L1 has a half-walked session +4. **Resume in progress** — shown when L1 has any session with `status='active'`. **Lists ALL active sessions, not just one**, sorted by `last_step_at DESC`. Each row shows ticket #, customer name, current node summary, "Step N · estimated M" or "Adhoc walk · {len(walk_notes)} notes". + +**Empty-state mode** (first-run experience) — shown when `count(flows) == 0 AND count(kb_documents) == 0` for the account: + +``` +┌──────────────────────────────────────────────────┐ +│ Good morning, {firstName}. │ +│ │ +│ ╔══════════════════════════════════════════════╗ │ +│ ║ Your knowledge base is empty ║ │ +│ ║ ║ │ +│ ║ L1 Workspace works best when your account ║ │ +│ ║ has KB content or authored flows. Right ║ │ +│ ║ now there's nothing to match against. ║ │ +│ ║ ║ │ +│ ║ [for L1 role:] ║ │ +│ ║ Ask your admin to: ║ │ +│ ║ • Upload KB documents ║ │ +│ ║ • Configure a KB connector (IT Glue, etc.) ║ │ +│ ║ • Or author a flow ║ │ +│ ║ ║ │ +│ ║ [for owner/coverage engineer:] ║ │ +│ ║ [ Upload KB content ] [ Configure connector ]│ │ +│ ║ ║ │ +│ ║ You can still take calls — they'll start ║ │ +│ ║ as ad-hoc walks. ║ │ +│ ╚══════════════════════════════════════════════╝ │ +│ │ +│ Describe the problem (still works — will start │ +│ as ad-hoc walk): │ +│ [ ... textarea ... ] │ +│ [ Start ad-hoc walk → ] │ +└──────────────────────────────────────────────────┘ +``` + +The empty-state card never blocks intake — an L1 can still take a call and the system gracefully starts an ad-hoc walk (since match_or_build will return `aborted_no_kb`). Tailwind v4 tokens: `bg-page` base, `bg-card` zones, `bg-elevated` row hover, electric-blue accent only on primary CTA. No `text-secondary`. All borders `border-default`. ### 7.4 `/l1/walk/{sessionId}` walker +The walker renders one of two variants based on `l1_walk_sessions.session_kind`: +- **Tree variant** (§7.4.A) — for `session_kind in ('flow', 'proposal')` +- **Adhoc variant** (§7.4.B) — for `session_kind = 'adhoc'` + +Both share the sticky header, persistent Escalate + Resolve buttons, customer info, and the resolve/escalate modals. + +#### 7.4.A Tree variant (flow + proposal walks) + Sticky header + two-pane body, full-height (flex chain per Lesson — every ancestor needs `flex` + `flex-1` + `min-h-0`). **Header:** -- Back arrow + ticket ref + customer name + AI-built badge (when target is proposal) +- Back arrow + ticket ref + customer name + AI-built badge (when `session_kind='proposal'`) - Problem statement line - Persistent action buttons: `[ Escalate ]` `[ Resolve ✓ ]` **Left pane (main):** - "Step N · estimated M" label - Current node card — large yes/no/answer buttons (min 44px tap target) -- Optional note textarea below the card (appended to `walked_path_snapshot`) +- Optional note textarea below the card (appended to `walked_path` as `l1_note`) - On a fresh proposal that's still building: shimmer placeholder + "Building from KB… ~10s" **Right pane (transcript):** @@ -379,14 +513,35 @@ Sticky header + two-pane body, full-height (flex chain per Lesson — every ance - Current step highlight - "Source:" section listing KB citations for the current node (proposal walks only) -**Resolve modal:** +#### 7.4.B Adhoc variant (no tree) + +Same sticky header (no AI-built badge since there's no tree). Single-pane body instead of two-pane: + +**Header:** +- Back arrow + ticket ref + customer name + "Ad-hoc walk" pill +- Problem statement line +- Persistent action buttons: `[ Escalate ]` `[ Resolve ✓ ]` + +**Body:** +- Large notes editor (rich-text-lite — paragraph breaks, bullet lists, no formatting toolbar bloat) +- Auto-save on debounce (300ms) to `l1_walk_sessions.walk_notes` via `POST /l1/sessions/{id}/notes` +- Subtle saved-state indicator ("Saved 2s ago") +- Optional "Add a step" button — appends a structured entry `{timestamp, content}` to `walk_notes` rather than free prose. Useful for recording sequential actions taken. + +**Why a separate variant rather than blank tree:** the tree pane is built around the question/answer/transcript trio. Forcing an adhoc session through that frame produces a confusing UX (empty transcript pane, no current node). A dedicated note-taking surface respects the L1's actual job in this mode. + +#### 7.4.C Resolve modal (both variants) + - "Did this resolve it?" `[ Yes ]` `[ No ]` -- Resolution notes textarea -- Yes + target was proposal → sets `validated_by_outcome=true` +- Resolution notes textarea (pre-filled with the most recent adhoc walk_notes entry if adhoc) +- Yes + target was proposal → sets `validated_by_outcome=true` on the proposal +- Yes + target was flow → no proposal change; flow's hit_count increments (telemetry only) +- Yes + adhoc → no proposal/flow change; resolution_notes saved on session and ticket - No → prompt to escalate instead -**Escalate modal:** -- Reason category dropdown: *Out of L1 scope · Customer demanding senior · Tree dead-ended · AI tree wrong · Other* +#### 7.4.D Escalate modal (both variants) + +- Reason category dropdown: *Out of L1 scope · Customer demanding senior · Tree dead-ended · AI tree wrong · No KB available · Other* - Free-text reason - Confirm @@ -427,6 +582,16 @@ Wrapped in `L1RouteGuard` (403 if not `l1_tech` AND not coverage-flagged). `Prot `lazyWithRetry`, not `React.lazy` (per existing convention). +### 7.9 Session lifecycle, concurrency, and recovery + +**Concurrent sessions:** an L1 may have multiple `l1_walk_sessions` rows with `status='active'` at the same time. The model imposes no single-session constraint — call patterns vary (one tech juggling two calls; one call drops and is resumed while another comes in; coverage engineer handling overflow). The dashboard's "Resume in progress" widget lists all active sessions ordered by `last_step_at DESC`. + +**Browser-close recovery:** every `POST /l1/sessions/{id}/step` and adhoc `POST /l1/sessions/{id}/notes` writes the incremental state to the server. If the browser closes mid-walk (crash, reload, accidental tab close), revisiting `/l1/walk/{sessionId}` reloads the session from `l1_walk_sessions` — current node, walked path so far, notes, customer info — and resumes exactly where the L1 left off. No client-side persistence required. + +**Abandoned sessions:** an APScheduler job (`max_instances=1`, hourly) flips sessions to `status='abandoned'` when `status='active' AND last_step_at < now() - interval '24 hours'`. Preserves the row for audit but removes it from the L1's "Resume in progress" widget. Abandoned sessions still appear in `/l1/drafts` filtered views if they walked a proposal. + +**No multi-tab guardrail in v1:** if the same L1 opens the same session in two tabs, last-write-wins on `walked_path`. Acceptable for v1 — multi-tab is rare in helpdesk workflows. v2 could add optimistic-locking on the session row. + --- ## 8. AI match-or-build pipeline @@ -450,23 +615,37 @@ match_or_build(account_id, problem_text, ticket_ref): if proposal_hits and proposal_hits[0].score >= MATCH_THRESHOLD: return {kind: 'proposal', id: proposal_hits[0].proposal_id, score: ...} - # 3. Build fresh + # 3. Near-miss zone: surface as suggestion, do NOT auto-build + near_miss = max( + (h for h in (flow_hits + proposal_hits) if h.score >= SUGGEST_THRESHOLD), + key=lambda h: h.score, + default=None, + ) + + # 4. Try to build fresh kb_chunks = rag_service.match_kb_chunks(account_id, embedding, k=8) if not kb_chunks: - raise BuildAbortedNoKB( - "Cannot build a tree with no KB content. " - "Upload docs or wait for a connector sync." - ) + return { + kind: 'aborted_no_kb', + near_miss: near_miss, # might still be useful as a starting point + } nearest_flows = flow_hits[:3] + if near_miss: + # Frontend prompts: "Found a similar flow — use it, or build new?" + return {kind: 'suggest', suggestion: near_miss, can_build: True} proposal = ai_tree_builder.build( problem_text, kb_chunks, nearest_flows, account_id, ticket_ref ) return {kind: 'proposal', id: proposal.id, score: None} ``` -`MATCH_THRESHOLD` — per-account configurable; default `0.75` (cosine). +**Thresholds (per-account configurable):** +- `MATCH_THRESHOLD` default `0.75` (cosine) — auto-use without asking +- `SUGGEST_THRESHOLD` default `0.60` (cosine) — surface as suggestion ("Found a similar flow — use it, or build new?") -The "no empty KB build" rule is enforced because an AI tree built on the model's general knowledge — without MSP-specific grounding — risks suggesting unsafe or hallucinated fixes. +**Near-miss handling rationale:** if a flow scores `0.74` against a `0.75` match threshold, building a fresh AI tree means a 5–15s wait when there's likely a directly usable flow already authored. Surfacing it as an L1 choice saves the build time and gives the L1 agency. Below `SUGGEST_THRESHOLD` (`0.60`), the match is too weak to be worth offering and we fall through to build (or abort). + +**The "no empty KB build" rule** is enforced because an AI tree built on the model's general knowledge — without MSP-specific grounding — risks suggesting unsafe or hallucinated fixes. When this aborts, the frontend renders the BuildAbortedNoKB UX (§8.4). ### 8.2 AI tree-build details @@ -495,6 +674,68 @@ The "no empty KB build" rule is enforced because an AI tree built on the model's After build, the writer verifies every `kb_doc_id` in `kb_citations` exists in the account's KB. Unverified citations are stripped from the walker's "Source:" pane (the node still renders, just without a source). Engineer review surfaces stripped citations as a warning. +### 8.4 BuildAbortedNoKB UX (live-call graceful degradation) + +The L1 is on a phone call when this fires. A generic "error" toast is unacceptable. The frontend renders a dedicated screen instead of navigating into a walker: + +``` +┌────────────────────────────────────────────────────┐ +│ No knowledge base content yet │ +│ │ +│ We couldn't match an existing flow and there's │ +│ nothing in your KB to build a new one from. │ +│ │ +│ You have three options for this call: │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Start an ad-hoc walk │ → │ ← primary CTA +│ │ Take notes, capture the resolution │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Escalate to engineering │ → │ +│ │ Reason pre-filled: "No KB available" │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ [ (near_miss present?) ] │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Try this similar flow instead │ → │ +│ │ "{near_miss.title}" · {score} match │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────────── │ +│ Tip: ask your admin to upload KB content or │ +│ configure a connector under Account → KB. │ +└────────────────────────────────────────────────────┘ +``` + +Each option triggers a distinct backend path: +- **Start an ad-hoc walk** → `POST /l1/sessions/adhoc` → creates `l1_walk_sessions` row with `session_kind='adhoc'`, no flow/proposal. Navigates to `/l1/walk/{id}` rendering the adhoc walker variant (§7.4.B). +- **Escalate** → `POST /l1/escalate-without-walk` (a thin variant of the session-escalate endpoint that takes no session id; creates an immediately-escalated session record and reassigns the ticket). Pre-fills `reason_category='No KB available'`. +- **Try similar flow** (only when `near_miss` was returned) → starts a flow session against the suggested flow, same as if matched. + +This is the **graceful degradation contract**: no L1 should ever hit a dead end on a live call. + +### 8.5 Near-miss "Suggest" UX + +When match_or_build returns `{kind: 'suggest', suggestion: ..., can_build: true}`, the intake response triggers an inline prompt on the dashboard (no full-page transition): + +``` +┌────────────────────────────────────────────────────┐ +│ Found a similar flow │ +│ │ +│ "Outlook can't connect after password reset" │ +│ Match: 67% · last updated 2 weeks ago │ +│ │ +│ [ Use this flow ] [ Build new tree ] │ +└────────────────────────────────────────────────────┘ +``` + +- **Use this flow** → starts a flow session against the suggestion. +- **Build new tree** → re-calls `match_or_build` with `force_build=true` parameter, bypasses the suggest pass, goes directly to build. + +This keeps the L1 in control while saving the 5–15s build time when there's an obvious starting point. + --- ## 9. KB ingestion @@ -528,6 +769,37 @@ All three deliver content to `kb_ingestion_writer` which: Cross-connector conflicts: same doc text appearing in two connectors yields two rows (provider-scoped `source_ref`). Engineers can dedup manually if needed. +### 9.2.1 Microsoft Graph OAuth flow (called out — non-trivial) + +Unlike IT Glue and Hudu (simple API token entry), Microsoft Graph requires a full OAuth 2.0 flow. This is materially more complex and worth specifying: + +**Prerequisites:** +- Register a Microsoft Entra ID app for ResolutionFlow. Single-tenant or multi-tenant: **multi-tenant** so MSPs can authorize against their own M365 tenants. +- Configured redirect URI: `https://resolutionflow.com/api/v1/kb-connectors/microsoft_graph/oauth/callback` (plus a localhost variant for dev). +- Scopes (least privilege): `Files.Read.All` + `Sites.Read.All` + `offline_access` (for refresh token). User must consent at the tenant level (admin consent required if the tenant has restricted user-consent). + +**Flow:** +1. Owner clicks "Connect SharePoint/OneDrive" on `/account/kb-connectors`. Frontend calls `POST /api/v1/kb-connectors` with `provider='microsoft_graph'` and minimal body (no credentials yet) → backend returns `{authorize_url}` with state token (signed JWT containing account_id + nonce, ~10min TTL). +2. Frontend opens `authorize_url` in a popup (preferred) or full-page redirect. User signs into Microsoft, consents. +3. Microsoft redirects to ResolutionFlow callback `/api/v1/kb-connectors/microsoft_graph/oauth/callback?code=...&state=...`. +4. Backend validates state JWT (extracts account_id, verifies nonce). Exchanges `code` for `{access_token, refresh_token, expires_in}` via Microsoft token endpoint. +5. Backend stores both tokens encrypted (Fernet) into `kb_connector_configs.credentials_encrypted` as a JSON blob `{access_token, refresh_token, expires_at, tenant_id}`. Sets `display_name` from the user's M365 tenant name. +6. Backend returns `{success: true}` to the popup window which postMessage's the parent and closes. + +**Site/drive selection:** +After the initial OAuth, owner picks which SharePoint sites and OneDrive drives to ingest. The connector exposes a discovery endpoint that lists available sites; owner picks. Selection persists in `kb_connector_configs.metadata` JSONB: `{site_ids: [...], drive_ids: [...]}`. + +**Access token refresh:** +The connector client (`services/kb_connectors/microsoft_graph.py`) wraps every API call: check `expires_at`, if within 5min of expiry call refresh endpoint, update stored tokens. Refresh failures (refresh_token expired or revoked) flip `kb_connector_configs.last_sync_status='auth_expired'` and surface in the connector status UI prompting owner to re-authorize. + +**Scope creep risk:** keep to `Files.Read.All` + `Sites.Read.All`. Do not request write scopes, mailbox scopes, or directory scopes even if convenient — read-only KB is the entire value prop. + +### 9.2.2 KB document visibility + +**Clarification (was ambiguous in initial spec):** `/account/kb` is owner + engineer accessible only. L1s do NOT see KB documents directly — they only see KB content surfaced via walker citations during a walk. This matches the principle that L1 staff are downstream consumers of the knowledge curated by their account's owner/engineers. + +Frontend route: `/account/kb` gated by `require_engineer_or_admin`. L1 hitting it → redirect to `/l1` with toast "KB management is owner/engineer only." + ### 9.3 Sync scheduling `kb_ingestion_scheduler.py` runs as APScheduler interval job, `max_instances=1`. Per cycle: @@ -567,6 +839,8 @@ Immediate sync via `POST /api/v1/kb-connectors/{id}/sync` enqueues a job; schedu **Un-escalate is out of scope.** If engineer wants to bounce back, they reassign in PSA manually. +**Known limitation — escalation notification noise:** "notify all engineers" is intentionally simple for v1 but does not scale. A 20-engineer account will get 20 bell badges per escalation, which trains everyone to ignore them. v2 work (§13) covers targeted notification — on-duty engineer presence, round-robin assignment, or an owner-designated escalation recipients list. Acknowledged as a real product issue, not a hidden one. + --- ## 11. Internal ticket fallback @@ -587,22 +861,31 @@ When the account has no active PSA provider: 1. L1 intake → match_or_build → FlowProposal(source='ai_realtime_l1', validated_by_outcome=false, linked_ticket_id=...) -2. L1 walks → POST /l1/sessions/{id}/step appends to walked_path_snapshot + → L1WalkSession(session_kind='proposal', + flow_proposal_id=..., + status='active') +2. L1 walks → POST /l1/sessions/{id}/step appends to l1_walk_sessions.walked_path + (NOTE: walked_path lives on the session, not the proposal — multiple L1s + may walk the same proposal independently) 3. L1 hits Resolve: modal: "Did this resolve it?" [Yes] [No] + resolution_notes -4. helpful=true → flow_proposal.validated_by_outcome = true - → walked_path_snapshot frozen +4. helpful=true → flow_proposal.validated_by_outcome = true (set if not already) + → l1_walk_sessions.status = 'resolved', helpful = true → ticket closed (PSA or internal) - helpful=false → validated_by_outcome stays false + helpful=false → flow_proposal.validated_by_outcome unchanged + → l1_walk_sessions.status = 'resolved', helpful = false → L1 prompted: "Escalate instead?" 5. Engineer review queue: ORDER BY validated_by_outcome DESC, created_at DESC - Outcome-validated drafts surface first + - Review pane shows the most recent helpful=true walk's walked_path as evidence - Promote / edit-and-promote / retire 6. Promote → new flow with source='ai_promoted'; original proposal kept with status='promoted' → future match_or_build matches the new flow on the flow-match pass ``` +**Why validated_by_outcome on the proposal but walked_path on the session:** `validated_by_outcome` is a one-bit signal that aggregates across all walks of a proposal (one L1 saying "this worked" is enough to flag the proposal as worth engineer attention). `walked_path` is the per-walk evidence and must be kept per-session — multiple paths through the same tree by different L1s tell different stories. Engineer review pulls the LATEST `helpful=true` session's path as the canonical "this is how it worked" record. + --- ## 13. Out of scope (v1 non-goals) @@ -620,6 +903,12 @@ When the account has no active PSA provider: - "Switch to L1 mode" persistent toggle for engineers (coverage flag + banner only). - Cancel/un-escalate flow. - Round-robin engineer assignment on internal-ticket escalations. +- **Targeted escalation notification** (on-duty presence, round-robin, owner-designated recipients) — v1 notifies all engineers; this will not scale past mid-size accounts. v2 work. +- **Quick-select problem shortcuts** on the L1 dashboard (top-N common problems as one-click intake buttons). Worth doing in v2 once telemetry reveals which problems dominate. Reduces typing on calls. +- **Rich-text resolution notes** with formatting toolbar. v1 is plain text + paragraph breaks only. +- **Multi-tab session locking** — last-write-wins on concurrent same-session edits in v1. +- **Step-by-step audit log rows** — v1 audits only at resolve/escalate (§5.6.1). Higher granularity is additive later. +- **Bulk KB document delete** in `/account/kb` — per-row delete only in v1. --- @@ -627,39 +916,63 @@ When the account has no active PSA provider: ### 14.1 Backend (pytest) -- Unit: `match_or_build` covers all four paths (flow-match, proposal-match, built, aborted_no_kb). +- Unit: `match_or_build` covers all five paths (flow-match, proposal-match, suggest, built, aborted_no_kb). Assert thresholds work at boundaries (score = MATCH_THRESHOLD, score = SUGGEST_THRESHOLD, etc.). - Unit: `ai_tree_builder` schema validation — assert rejection of malformed Anthropic output before persistence. - Unit: each connector's `list_documents` + `fetch_content` against recorded HTTP fixtures. -- Integration: intake → walk → resolve(helpful=true) → assert `FlowProposal.validated_by_outcome=true`, ticket closed. -- Integration: intake → walk → escalate → assert PSA `reassign_ticket` invoked, `ai_session` created with package, audit log entry, notification dispatched. +- Unit: Microsoft Graph OAuth flow — state JWT validation, token exchange, refresh, auth-expired surfacing. +- Unit: `seat_enforcement.check_seat_available` — engineer + L1 paths, grandfathered case. +- Integration: intake → walk(flow) → resolve(helpful=true) → assert flow's hit_count incremented, ticket closed (no proposal change). +- Integration: intake → walk(proposal) → resolve(helpful=true) → assert `FlowProposal.validated_by_outcome=true`, `l1_walk_sessions.helpful=true`, ticket closed. +- Integration: intake → walk → escalate → assert PSA `reassign_ticket` invoked, `ai_session` created with package, audit log entry written ONLY at escalate (not steps), notification dispatched. +- Integration: intake on empty-KB account → assert `outcome='aborted_no_kb'` returned, no proposal created. +- Integration: `/l1/sessions/adhoc` → walker variant flag set → resolve → ticket closed, no proposal/flow touched. +- Integration: `/l1/escalate-without-walk` → escalated session row created, no walked_path, package generated. - Integration: KB scheduler — `max_instances=1`, sequential per-account, soft-delete on removal. -- **RLS regression** (highest priority): `l1_tech` user in account A cannot read account B's tickets, drafts, KB docs, or connector configs. Added to existing RLS test suite. +- Integration: Microsoft Graph refresh-token expiry → `last_sync_status='auth_expired'` surfaced. +- Integration: invite past seat limit → 402 returned; accept-invite at limit → 422; role-change at limit → blocked. +- Integration: grandfathered over-seated account → existing users keep access, new invite blocks. +- Integration: concurrent session creation by same L1 → both rows persist, dashboard returns both in "Resume in progress" sorted by `last_step_at DESC`. +- Integration: session abandonment job — flips `status='active'` rows with `last_step_at < now() - 24h` to `'abandoned'`. +- **RLS regression** (highest priority): `l1_tech` user in account A cannot read account B's tickets, drafts, KB docs, connector configs, or walk sessions. Added to existing RLS test suite. - Anti-parrot: existing CI test auto-discovers new prompt module. ### 14.2 Frontend - Unit: `usePermissions` — L1 sees L1 paths, blocked from engineer paths. Coverage flag opens L1 paths. -- Unit: `L1WalkPage` — node advance, escalate modal, resolve modal flips `validated_by_outcome` correctly. +- Unit: `L1WalkPage` tree variant — node advance, escalate modal, resolve modal flips `validated_by_outcome` correctly. +- Unit: `L1WalkPage` adhoc variant — notes auto-save (debounced), no node card rendered, resolve uses notes as resolution_notes pre-fill. +- Unit: `L1Dashboard` empty-state — renders empty card when flows+KB are both zero; intake still works. +- Unit: `L1Dashboard` resume-in-progress — lists multiple active sessions ordered by `last_step_at DESC`. - Unit: `L1CoverageBanner` — visible for engineer-with-flag on `/l1/*`, hidden for L1 users. +- Unit: BuildAbortedNoKB screen — renders three CTAs (with/without near_miss), routes correctly to adhoc/escalate/use-suggestion. +- Unit: SuggestPrompt component — accepts a suggestion, "Build new tree" re-calls intake with `force_build=true`. - E2E (Playwright, scoped selectors per Lesson): - L1 sign-in → dashboard → intake → walker → resolve → verify ticket closed + proposal flagged. + - L1 on empty-KB account → intake → BuildAbortedNoKB screen → "Start ad-hoc walk" → adhoc walker → resolve. + - L1 with near-miss → intake → suggest prompt → "Use this flow" → flow walker. + - L1 browser-close mid-walk → re-open `/l1/walk/{id}` → state restored. - Engineer with `can_cover_l1` → sidebar entry visible → click → coverage banner shows → walks a session → audit log records `acting_as='l1_coverage'`. - - L1 hitting `/pilot`, `/trees/new`, `/escalations` → 403 or redirect. + - Owner invites past seat limit → blocked with upgrade prompt. + - L1 hitting `/pilot`, `/trees/new`, `/escalations`, `/account/kb` → 403 or redirect. --- ## 15. Acceptance criteria (v1 ships when…) - L1 role assignable; assigned L1 sees L1 sidebar only; no engineer route reachable. -- L1 intake creates a ticket (PSA or internal) and lands in walker session. -- Walker handles both flows and proposals; AI-built badge + sources shown for proposals. -- Escalate generates package, reassigns ticket, notifies engineers. -- Resolve flips `validated_by_outcome`; review queue prioritizes outcome-validated drafts. -- All three KB connectors configurable; initial sync + periodic re-sync + soft-delete on removal. -- AI build refuses with informative error when account KB is empty. -- Coverage flag works end-to-end with audit-log tagging. -- RLS blocks cross-tenant reads on every new table. -- L1 seat count tracked separately from engineer seats in admin/billing UI. +- L1 intake creates a ticket (PSA or internal) and lands in walker session — OR renders the BuildAbortedNoKB screen when KB is empty, OR renders the suggest prompt when near-miss exists. +- Walker handles flow walks, proposal walks, AND adhoc walks (single-pane note-taking variant). All three resolve and escalate correctly. +- Concurrent sessions supported; browser-close mid-walk recoverable; abandoned sessions auto-flipped after 24h inactivity. +- First-run empty-state card renders on dashboard when account has no flows AND no KB docs; intake still works (degrades to adhoc). +- Escalate generates package, reassigns ticket, notifies engineers. Escalate from BuildAbortedNoKB pre-fills reason category. +- Resolve flips `validated_by_outcome` on proposals; review queue prioritizes outcome-validated drafts and surfaces the latest helpful walk's path as evidence. +- All three KB connectors configurable; initial sync + periodic re-sync + soft-delete on removal. Microsoft Graph OAuth flow completes end-to-end including refresh token rotation. +- AI build refuses cleanly when account KB is empty (returns `aborted_no_kb`, not an exception). +- Coverage flag works end-to-end with audit-log tagging (`acting_as='l1_coverage'`). +- Seat enforcement: invite blocks with structured 402/422 when target-role seats are exhausted, for BOTH L1 and engineer roles. +- RLS blocks cross-tenant reads on every new table (`l1_walk_sessions`, `internal_tickets`, `kb_connector_configs`, `kb_documents`, `kb_document_chunks`). +- L1 seat count tracked separately from engineer seats; seat counter widget visible in admin/users UI. +- L1s cannot access `/account/kb` (owner+engineer only) — confirmed by route guard test. --- @@ -670,10 +983,13 @@ When the account has no active PSA provider: | AI builds an unsafe tree | Schema validation rejects malformed output. Engineer review is the gate before draft becomes "real" flow. v1 refuses to build when KB is empty. | | Hallucinated KB citations | Post-build verification that each `kb_doc_id` exists; unverified citations stripped from walker, surfaced as warning in engineer review. | | Duplicate proposals for same problem | Validated-proposal match pass deduplicates after one L1 validates; pre-validation dups are tolerated and dedup'd during engineer review. | -| KB ingestion captures sensitive content | Per-connector deny-lists (passwords, sensitive flex assets, MS Graph Sensitivity Labels). Owners exclude specific folders/sites at config. All ingested docs visible in `/account/kb` for manual deletion. | +| KB ingestion captures sensitive content | Per-connector deny-lists (passwords, sensitive flex assets, MS Graph Sensitivity Labels). Owners exclude specific folders/sites at config. Ingested docs visible only to owners + engineers (NOT L1s) in `/account/kb` for manual deletion. | | AI build latency frustrates customer on call | Build-progress UI sets expectation. Escalate button visible from page load. Future: pre-warm builds on PSA-ticket-landed event. | -| Three connectors is more scope than originally proposed | Acknowledged. Each connector is ~1–2 weeks of work. Plan should sequence them and allow shipping with IT Glue + Hudu first if SharePoint slips. | +| Three connectors is more scope than originally proposed | Acknowledged. Each connector is ~1–2 weeks of work; Microsoft Graph OAuth is the heaviest (§9.2.1). Plan should sequence them and allow shipping with IT Glue + Hudu first if SharePoint slips. | | Engineer review queue backlog stalls library growth | Validated-proposal match pass means good drafts get reused without engineer review. Backlog only delays the move from `'proposal'` to `'flow'`, not the L1's ability to use validated content. | +| `walked_path` JSONB grows unboundedly on long calls with many notes | Per-call paths are bounded by tree depth (typically <20 nodes); per-L1 notes are typically short. Real risk only emerges for adhoc walks with verbose note-taking on multi-hour calls. v1 caps `walk_notes` JSONB at 256 KB at the API layer with a 400 error and "notes too long — consider escalating." Future v2: normalize notes into a separate `l1_walk_notes` table if size becomes a real issue. | +| Engineer notification overload at scale | Acknowledged — see §10 "Known limitation." v1 notifies all engineers; v2 work covers targeted notification. Mid-size accounts (10+ engineers) will feel this first; flag in onboarding docs. | +| L1 seat enforcement breaks for accounts grandfathered over their seat count | §3.6 specifies non-retroactive enforcement: existing over-seated accounts get a banner but functionality is preserved until next invite. Confirm test coverage for grandfathered state. | --- From d40cb834b163c2906721e8825f1d56c15eaa5a4d Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 28 May 2026 11:58:41 -0400 Subject: [PATCH 04/42] docs(plan): L1 workspace Phase 1 implementation plan 26 bite-sized TDD tasks covering: l1_tech role + perms, seat enforcement (L1 + engineer together), 5 migrations (role/columns, FlowProposal, internal_tickets, l1_walk_sessions), seat_enforcement/internal_ticket/ l1_session services, full L1 endpoint surface (intake/queue/step/notes/ resolve/escalate/escalate-without-walk), APScheduler cleanup for 24h abandoned sessions, frontend usePermissions/Sidebar/router updates, L1Dashboard (active + empty state + resume widget), L1WalkPage with tree and adhoc variants, coverage banner, seat counter widget, RLS regression tests, E2E Playwright suite, acceptance walkthrough. Phase 2 (AI build + KB documents) and Phase 3 (KB connectors) get their own plan files. Phase 1 ships with adhoc walks as the default intake; user-facing flow selection ships in Phase 2 alongside the AI matcher. PSA close/reassign is a Phase 1 stub (deferred to Phase 2). Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-05-28-l1-workspace-phase-1.md | 4092 +++++++++++++++++ 1 file changed, 4092 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-l1-workspace-phase-1.md diff --git a/docs/superpowers/plans/2026-05-28-l1-workspace-phase-1.md b/docs/superpowers/plans/2026-05-28-l1-workspace-phase-1.md new file mode 100644 index 00000000..db5ac99e --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-l1-workspace-phase-1.md @@ -0,0 +1,4092 @@ +# L1 Workspace — Phase 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the L1 helpdesk workspace — new role, dedicated `/l1/*` surface, walker (tree + adhoc variants), session lifecycle, escalation, internal-ticket fallback, coverage flag, seat enforcement — working end-to-end against existing authored flows. + +**Architecture:** New `l1_tech` role between `engineer` and `viewer`. Two new tables (`l1_walk_sessions`, `internal_tickets`) and small column additions to `flow_proposals`, `users`, `accounts`, `subscriptions`, `audit_logs`. New `services/l1_session_service.py` orchestrates walking-session lifecycle. New `services/seat_enforcement.py` shared by invite/role-change paths for both L1 and engineer seats. Frontend gets a role-gated `/l1/*` page tree. AI tree-builder and KB connectors are explicitly out of Phase 1 (Phases 2 and 3). + +**Tech Stack:** Python 3.12 + FastAPI + SQLAlchemy 2.0 async + Alembic + Pydantic v2 + APScheduler (backend). React 19 + Vite + TypeScript + Tailwind v4 + Zustand + React Router v7 (frontend). PostgreSQL 16 with RLS. + +**Spec:** [docs/superpowers/specs/2026-05-28-l1-workspace-design.md](../specs/2026-05-28-l1-workspace-design.md) — read in full before starting. + +**Out of scope for Phase 1** (deferred to Phase 2/3): `match_or_build` orchestrator, AI tree-builder, `kb_documents` tables, KB connectors (IT Glue / Hudu / Microsoft Graph), `SUGGEST_THRESHOLD` near-miss UX, BuildAbortedNoKB screen with near-miss option. **User-facing flow selection** is also deferred — Phase 1 intake creates adhoc walks only; the walker's flow variant exists in code and works when `flow_id` is passed to the intake endpoint (used by tests + direct API), but the UI surface for L1s to manually pick a flow ships in Phase 2 alongside the AI matcher. FlowProposal column extensions ARE included in Phase 1 (model is ready; Phase 2 populates). + +--- + +## File Structure + +**Backend — new files:** +- `backend/app/models/l1_walk_session.py` — SQLAlchemy `L1WalkSession` +- `backend/app/models/internal_ticket.py` — SQLAlchemy `InternalTicket` +- `backend/app/schemas/l1.py` — Pydantic request/response shapes +- `backend/app/schemas/seat_enforcement.py` — `SeatCheckResult`, `SeatUsage` shapes +- `backend/app/services/seat_enforcement.py` — `check_seat_available` shared helper +- `backend/app/services/internal_ticket_service.py` — CRUD + status transitions +- `backend/app/services/l1_session_service.py` — session lifecycle (start/step/notes/resolve/escalate) +- `backend/app/services/l1_session_cleanup.py` — APScheduler hourly job, 24h abandonment +- `backend/app/api/endpoints/l1.py` — all `/l1/*` endpoints +- `backend/alembic/versions/_add_l1_tech_role.py` — role + column additions +- `backend/alembic/versions/_extend_flow_proposals.py` — FlowProposal columns +- `backend/alembic/versions/_create_internal_tickets.py` — table + RLS +- `backend/alembic/versions/_create_l1_walk_sessions.py` — table + RLS + check constraint +- `backend/tests/test_seat_enforcement.py` +- `backend/tests/test_internal_ticket_service.py` +- `backend/tests/test_l1_session_service.py` +- `backend/tests/test_l1_endpoints.py` +- `backend/tests/test_l1_rls.py` + +**Backend — modified files:** +- `backend/app/models/flow_proposal.py` — add new columns +- `backend/app/models/user.py` — add `can_cover_l1` +- `backend/app/models/account.py` — add `l1_seats_purchased` +- `backend/app/models/subscription.py` — add `l1_seat_limit` +- `backend/app/models/audit_log.py` — add `acting_as` +- `backend/app/core/permissions.py` — add `l1_tech` to role docstring + helpers +- `backend/app/api/deps.py` — add `require_l1`, `require_l1_or_coverage`, `require_l1_or_above` +- `backend/app/api/router.py` — register `l1` router + `internal-tickets` router +- `backend/app/api/endpoints/invite.py` — integrate seat enforcement +- `backend/app/api/endpoints/accounts.py` — seat-usage endpoint + coverage PATCH + role-change check +- `backend/app/main.py` — register cleanup scheduler in lifespan + +**Frontend — new files:** +- `frontend/src/pages/l1/L1Dashboard.tsx` +- `frontend/src/pages/l1/L1WalkPage.tsx` +- `frontend/src/pages/l1/L1DraftsPage.tsx` +- `frontend/src/pages/l1/L1TicketsPage.tsx` +- `frontend/src/components/l1/L1WalkTreeVariant.tsx` +- `frontend/src/components/l1/L1WalkAdhocVariant.tsx` +- `frontend/src/components/l1/L1CoverageBanner.tsx` +- `frontend/src/components/l1/EmptyStateCard.tsx` +- `frontend/src/components/l1/ResumeInProgress.tsx` +- `frontend/src/components/admin/SeatCounterWidget.tsx` +- `frontend/src/components/layout/L1RouteGuard.tsx` +- `frontend/src/api/l1.ts` +- `frontend/src/types/l1.ts` + +**Frontend — modified files:** +- `frontend/src/hooks/usePermissions.ts` — add `isL1Tech`, `canCoverL1`, role-tier check +- `frontend/src/components/layout/Sidebar.tsx` — role-based nav array +- `frontend/src/components/layout/ProtectedRoute.tsx` — L1 post-login redirect +- `frontend/src/router.tsx` — register `/l1/*` routes +- `frontend/src/types/auth.ts` (or wherever `User.account_role` lives) — add `'l1_tech'` to union +- `frontend/src/api/index.ts` — export `l1` API +- `frontend/src/types/index.ts` — export `l1` types + +--- + +## Task 1: Backend — extend role docstring + permission helpers + +**Files:** +- Modify: `backend/app/core/permissions.py` (header docstring + any role-list constants) + +- [ ] **Step 1: Open file and locate the role docstring (around lines 5–10).** + +Read [permissions.py](../../backend/app/core/permissions.py) to confirm current shape. + +- [ ] **Step 2: Add `l1_tech` to the role docstring.** + +Replace the existing role list block: + +```python +""" +Permissions module. + +Role hierarchy: +- super_admin: is_super_admin=True, full system access +- owner: account_role='owner', manage account resources +- engineer: account_role='engineer' (default), CRUD own trees/steps +- l1_tech: account_role='l1_tech', use /l1/* surface only — walk flows, resolve/escalate +- viewer: account_role='viewer', read-only (can browse, run sessions, rate steps) +""" +``` + +- [ ] **Step 3: If there's a `VALID_ROLES` constant or similar enum/list, add `'l1_tech'`.** + +Grep first: + +```bash +grep -n "engineer.*viewer\|VALID_ROLES\|ROLE_HIERARCHY" backend/app/core/permissions.py +``` + +If a list/tuple exists, insert `'l1_tech'` between `'engineer'` and `'viewer'`. If not, no change. + +- [ ] **Step 4: No tests for this docstring-only change. Move to commit.** + +- [ ] **Step 5: Commit.** + +```bash +git add backend/app/core/permissions.py +git commit -m "feat(l1): add l1_tech role to permissions docstring" +``` + +--- + +## Task 2: Backend — add `require_l1`, `require_l1_or_coverage`, `require_l1_or_above` deps + +**Files:** +- Modify: `backend/app/api/deps.py` (add new dep functions after `require_engineer_or_admin`) +- Test: `backend/tests/test_deps_l1.py` (new) + +- [ ] **Step 1: Write the failing tests.** + +Create `backend/tests/test_deps_l1.py`: + +```python +import pytest +from fastapi import HTTPException +from app.api.deps import require_l1, require_l1_or_coverage, require_l1_or_above +from tests.factories import make_user # existing test factory + + +@pytest.mark.asyncio +async def test_require_l1_passes_for_l1_tech(): + user = make_user(account_role='l1_tech') + result = await require_l1(current_user=user) + assert result is user + + +@pytest.mark.asyncio +async def test_require_l1_blocks_engineer(): + user = make_user(account_role='engineer') + with pytest.raises(HTTPException) as exc: + await require_l1(current_user=user) + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_require_l1_or_coverage_passes_engineer_with_flag(): + user = make_user(account_role='engineer', can_cover_l1=True) + result = await require_l1_or_coverage(current_user=user) + assert result is user + + +@pytest.mark.asyncio +async def test_require_l1_or_coverage_blocks_engineer_without_flag(): + user = make_user(account_role='engineer', can_cover_l1=False) + with pytest.raises(HTTPException) as exc: + await require_l1_or_coverage(current_user=user) + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_require_l1_or_coverage_passes_owner_always(): + user = make_user(account_role='owner', can_cover_l1=False) + result = await require_l1_or_coverage(current_user=user) + assert result is user + + +@pytest.mark.asyncio +async def test_require_l1_or_above_passes_engineer(): + user = make_user(account_role='engineer') + result = await require_l1_or_above(current_user=user) + assert result is user + + +@pytest.mark.asyncio +async def test_require_l1_or_above_blocks_viewer(): + user = make_user(account_role='viewer') + with pytest.raises(HTTPException) as exc: + await require_l1_or_above(current_user=user) + assert exc.value.status_code == 403 +``` + +- [ ] **Step 2: Run tests to verify they fail.** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_deps_l1.py -v +``` +Expected: ImportError (require_l1 etc. don't exist yet). + +- [ ] **Step 3: Add the new deps in `backend/app/api/deps.py`.** + +After the existing `require_engineer_or_admin` definition, add: + +```python +async def require_l1( + current_user: User = Depends(get_current_active_user), +) -> User: + """L1 tech only (exact match). Used by endpoints exclusive to L1 self-service.""" + if current_user.is_super_admin: + return current_user # super_admin bypass for support purposes + if current_user.account_role != "l1_tech": + raise HTTPException(status_code=403, detail="L1 tech role required") + return current_user + + +async def require_l1_or_coverage( + current_user: User = Depends(get_current_active_user), +) -> User: + """ + L1 endpoints accessible to: l1_tech, engineers with can_cover_l1, owners, super_admin. + The "coverage" tier — engineers covering a frontline shift. + """ + if current_user.is_super_admin: + return current_user + role = current_user.account_role + if role == "l1_tech": + return current_user + if role == "owner": + return current_user + if role == "engineer" and current_user.can_cover_l1: + return current_user + raise HTTPException( + status_code=403, + detail="L1 access requires l1_tech role or engineer coverage flag", + ) + + +async def require_l1_or_above( + current_user: User = Depends(get_current_active_user), +) -> User: + """ + Anyone at L1 tier or higher. Used for shared resources L1s can see + (e.g., flow library, KB connector list view). + """ + if current_user.is_super_admin: + return current_user + if current_user.account_role in ("l1_tech", "engineer", "owner"): + return current_user + raise HTTPException(status_code=403, detail="L1 or above required") +``` + +Also ensure `User` model is imported at the top of `deps.py` (likely already imported). + +- [ ] **Step 4: Run tests to verify they pass.** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_deps_l1.py -v +``` +Expected: 7 PASSED. + +- [ ] **Step 5: Commit.** + +```bash +git add backend/app/api/deps.py backend/tests/test_deps_l1.py +git commit -m "feat(l1): add require_l1, require_l1_or_coverage, require_l1_or_above deps" +``` + +--- + +## Task 3: Migration — column additions (can_cover_l1, l1_seats_purchased, audit_logs.acting_as, l1_seat_limit) + +**Files:** +- Create: `backend/alembic/versions/_add_l1_columns.py` +- Modify: `backend/app/models/user.py` (add `can_cover_l1`) +- Modify: `backend/app/models/account.py` (add `l1_seats_purchased`) +- Modify: `backend/app/models/subscription.py` (add `l1_seat_limit`) +- Modify: `backend/app/models/audit_log.py` (add `acting_as`) + +- [ ] **Step 1: Generate the manual migration (no autogenerate).** + +```bash +docker exec -w /app resolutionflow_backend alembic revision -m "add_l1_columns" +``` +This creates a file `backend/alembic/versions/_add_l1_columns.py`. Note the hash from the output. + +- [ ] **Step 2: Write the migration content.** + +Open the generated file and replace the `upgrade()` / `downgrade()` bodies: + +```python +def upgrade() -> None: + op.add_column( + 'users', + sa.Column('can_cover_l1', sa.Boolean(), nullable=False, server_default='false'), + ) + op.add_column( + 'accounts', + sa.Column('l1_seats_purchased', sa.Integer(), nullable=False, server_default='0'), + ) + op.add_column( + 'subscriptions', + sa.Column('l1_seat_limit', sa.Integer(), nullable=True), + ) + op.add_column( + 'audit_logs', + sa.Column('acting_as', sa.String(30), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('audit_logs', 'acting_as') + op.drop_column('subscriptions', 'l1_seat_limit') + op.drop_column('accounts', 'l1_seats_purchased') + op.drop_column('users', 'can_cover_l1') +``` + +- [ ] **Step 3: Add the matching columns to the SQLAlchemy models.** + +In `backend/app/models/user.py`, add inside the `User` class column block: +```python +can_cover_l1: Mapped[bool] = mapped_column( + sa.Boolean(), nullable=False, server_default=sa.text('false') +) +``` + +In `backend/app/models/account.py`: +```python +l1_seats_purchased: Mapped[int] = mapped_column( + sa.Integer(), nullable=False, server_default=sa.text('0') +) +``` + +In `backend/app/models/subscription.py`: +```python +l1_seat_limit: Mapped[Optional[int]] = mapped_column(sa.Integer(), nullable=True) +``` + +In `backend/app/models/audit_log.py`: +```python +acting_as: Mapped[Optional[str]] = mapped_column(sa.String(30), nullable=True) +``` + +- [ ] **Step 4: Run migration to verify it applies.** + +```bash +docker exec -w /app resolutionflow_backend alembic upgrade head +``` +Expected: applies successfully, no errors. + +- [ ] **Step 5: Verify schema via psql.** + +```bash +docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow -c "\d users" | grep can_cover_l1 +docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow -c "\d accounts" | grep l1_seats_purchased +docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow -c "\d subscriptions" | grep l1_seat_limit +docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow -c "\d audit_logs" | grep acting_as +``` +Expected: each grep returns the new column row. + +- [ ] **Step 6: Verify downgrade works (then upgrade again).** + +```bash +docker exec -w /app resolutionflow_backend alembic downgrade -1 +docker exec -w /app resolutionflow_backend alembic upgrade head +``` + +- [ ] **Step 7: Commit.** + +```bash +git add backend/alembic/versions/_add_l1_columns.py backend/app/models/user.py backend/app/models/account.py backend/app/models/subscription.py backend/app/models/audit_log.py +git commit -m "feat(l1): add column additions migration (can_cover_l1, l1_seats_purchased, l1_seat_limit, acting_as)" +``` + +--- + +## Task 4: Migration — extend `flow_proposals` with L1 source columns + +**Files:** +- Create: `backend/alembic/versions/_extend_flow_proposals.py` +- Modify: `backend/app/models/flow_proposal.py` + +Spec §5.1: add `source`, `linked_ticket_id`, `linked_ticket_kind`, `validated_by_outcome` to `flow_proposals`. (`walked_path_snapshot` is NOT added — it lives on `l1_walk_sessions` per the revised design.) + +- [ ] **Step 1: Generate migration.** + +```bash +docker exec -w /app resolutionflow_backend alembic revision -m "extend_flow_proposals_l1" +``` + +- [ ] **Step 2: Write migration content.** + +```python +def upgrade() -> None: + op.add_column( + 'flow_proposals', + sa.Column('source', sa.String(30), nullable=True), + ) + op.add_column( + 'flow_proposals', + sa.Column('linked_ticket_id', sa.String(64), nullable=True), + ) + op.add_column( + 'flow_proposals', + sa.Column('linked_ticket_kind', sa.String(10), nullable=True), + ) + op.add_column( + 'flow_proposals', + sa.Column('validated_by_outcome', sa.Boolean(), nullable=False, server_default='false'), + ) + + # Backfill existing rows + op.execute("UPDATE flow_proposals SET source = 'manual_draft' WHERE source IS NULL") + + # Now enforce NOT NULL on source + op.alter_column('flow_proposals', 'source', nullable=False) + + # CHECK constraint on source values + op.create_check_constraint( + 'ck_flow_proposals_source', + 'flow_proposals', + "source IN ('ai_realtime_l1', 'kb_accelerator', 'manual_draft', 'ai_promoted')", + ) + + # CHECK constraint on linked_ticket_kind values + op.create_check_constraint( + 'ck_flow_proposals_linked_ticket_kind', + 'flow_proposals', + "linked_ticket_kind IS NULL OR linked_ticket_kind IN ('psa', 'internal')", + ) + + +def downgrade() -> None: + op.drop_constraint('ck_flow_proposals_linked_ticket_kind', 'flow_proposals', type_='check') + op.drop_constraint('ck_flow_proposals_source', 'flow_proposals', type_='check') + op.drop_column('flow_proposals', 'validated_by_outcome') + op.drop_column('flow_proposals', 'linked_ticket_kind') + op.drop_column('flow_proposals', 'linked_ticket_id') + op.drop_column('flow_proposals', 'source') +``` + +- [ ] **Step 3: Update `FlowProposal` model.** + +In `backend/app/models/flow_proposal.py`, add inside the class: + +```python +source: Mapped[str] = mapped_column(sa.String(30), nullable=False, server_default=sa.text("'manual_draft'")) +linked_ticket_id: Mapped[Optional[str]] = mapped_column(sa.String(64), nullable=True) +linked_ticket_kind: Mapped[Optional[str]] = mapped_column(sa.String(10), nullable=True) +validated_by_outcome: Mapped[bool] = mapped_column( + sa.Boolean(), nullable=False, server_default=sa.text('false') +) +``` + +Also update `__table_args__` to include the new check constraints: + +```python +__table_args__ = ( + # ... existing constraints ... + CheckConstraint( + "source IN ('ai_realtime_l1', 'kb_accelerator', 'manual_draft', 'ai_promoted')", + name="ck_flow_proposals_source", + ), + CheckConstraint( + "linked_ticket_kind IS NULL OR linked_ticket_kind IN ('psa', 'internal')", + name="ck_flow_proposals_linked_ticket_kind", + ), +) +``` + +- [ ] **Step 4: Apply migration.** + +```bash +docker exec -w /app resolutionflow_backend alembic upgrade head +``` + +- [ ] **Step 5: Verify with psql.** + +```bash +docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow -c "\d flow_proposals" +``` +Expected: shows new columns + check constraints. + +- [ ] **Step 6: Commit.** + +```bash +git add backend/alembic/versions/_extend_flow_proposals.py backend/app/models/flow_proposal.py +git commit -m "feat(l1): extend FlowProposal with source/linked_ticket/validated_by_outcome" +``` + +--- + +## Task 5: Migration + model — create `internal_tickets` table + +**Files:** +- Create: `backend/alembic/versions/_create_internal_tickets.py` +- Create: `backend/app/models/internal_ticket.py` + +- [ ] **Step 1: Generate migration.** + +```bash +docker exec -w /app resolutionflow_backend alembic revision -m "create_internal_tickets" +``` + +- [ ] **Step 2: Write migration content.** + +```python +def upgrade() -> None: + op.create_table( + 'internal_tickets', + sa.Column('id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('account_id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('created_by_user_id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('customer_name', sa.String(120), nullable=True), + sa.Column('customer_contact', sa.String(200), nullable=True), + sa.Column('problem_statement', sa.Text(), nullable=False), + sa.Column('status', sa.String(30), nullable=False, server_default='open'), + sa.Column('flow_id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('flow_proposal_id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('ai_session_id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('assigned_user_id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('resolution_notes', sa.Text(), nullable=True), + sa.Column('psa_promoted_ticket_id', sa.String(64), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), + sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['flow_id'], ['trees.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['flow_proposal_id'], ['flow_proposals.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['ai_session_id'], ['ai_sessions.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['assigned_user_id'], ['users.id'], ondelete='SET NULL'), + sa.CheckConstraint( + "status IN ('open', 'walking', 'resolved', 'escalated')", + name='ck_internal_tickets_status', + ), + ) + op.create_index('ix_internal_tickets_account_id', 'internal_tickets', ['account_id']) + op.create_index('ix_internal_tickets_status', 'internal_tickets', ['status']) + op.create_index('ix_internal_tickets_assigned_user_id', 'internal_tickets', ['assigned_user_id']) + + # RLS — match the project pattern + op.execute("ALTER TABLE internal_tickets ENABLE ROW LEVEL SECURITY") + op.execute(""" + CREATE POLICY internal_tickets_account_isolation ON internal_tickets + USING (account_id = current_setting('app.current_account_id')::uuid) + WITH CHECK (account_id = current_setting('app.current_account_id')::uuid) + """) + + +def downgrade() -> None: + op.execute("DROP POLICY IF EXISTS internal_tickets_account_isolation ON internal_tickets") + op.execute("ALTER TABLE internal_tickets DISABLE ROW LEVEL SECURITY") + op.drop_index('ix_internal_tickets_assigned_user_id', 'internal_tickets') + op.drop_index('ix_internal_tickets_status', 'internal_tickets') + op.drop_index('ix_internal_tickets_account_id', 'internal_tickets') + op.drop_table('internal_tickets') +``` + +- [ ] **Step 3: Create the SQLAlchemy model.** + +Create `backend/app/models/internal_ticket.py`: + +```python +import uuid +from datetime import datetime +from typing import Optional + +import sqlalchemy as sa +from sqlalchemy import CheckConstraint, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class InternalTicket(Base): + __tablename__ = "internal_tickets" + __table_args__ = ( + CheckConstraint( + "status IN ('open', 'walking', 'resolved', 'escalated')", + name="ck_internal_tickets_status", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("accounts.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + created_by_user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + ) + customer_name: Mapped[Optional[str]] = mapped_column(sa.String(120), nullable=True) + customer_contact: Mapped[Optional[str]] = mapped_column(sa.String(200), nullable=True) + problem_statement: Mapped[str] = mapped_column(sa.Text(), nullable=False) + status: Mapped[str] = mapped_column( + sa.String(30), nullable=False, server_default=sa.text("'open'"), index=True + ) + flow_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("trees.id", ondelete="SET NULL"), nullable=True + ) + flow_proposal_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("flow_proposals.id", ondelete="SET NULL"), nullable=True + ) + ai_session_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="SET NULL"), nullable=True + ) + assigned_user_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + resolution_notes: Mapped[Optional[str]] = mapped_column(sa.Text(), nullable=True) + psa_promoted_ticket_id: Mapped[Optional[str]] = mapped_column(sa.String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()") + ) + updated_at: Mapped[datetime] = mapped_column( + sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), + onupdate=sa.text("now()"), + ) + resolved_at: Mapped[Optional[datetime]] = mapped_column(sa.DateTime(timezone=True), nullable=True) +``` + +- [ ] **Step 4: Apply migration.** + +```bash +docker exec -w /app resolutionflow_backend alembic upgrade head +``` + +- [ ] **Step 5: Verify RLS policy via psql.** + +```bash +docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow -c "SELECT polname, polcmd, polqual FROM pg_policy WHERE polrelid = 'internal_tickets'::regclass" +``` +Expected: one policy row `internal_tickets_account_isolation`. + +- [ ] **Step 6: Commit.** + +```bash +git add backend/alembic/versions/_create_internal_tickets.py backend/app/models/internal_ticket.py +git commit -m "feat(l1): create internal_tickets table with RLS" +``` + +--- + +## Task 6: Migration + model — create `l1_walk_sessions` table + +**Files:** +- Create: `backend/alembic/versions/_create_l1_walk_sessions.py` +- Create: `backend/app/models/l1_walk_session.py` + +- [ ] **Step 1: Generate migration.** + +```bash +docker exec -w /app resolutionflow_backend alembic revision -m "create_l1_walk_sessions" +``` + +- [ ] **Step 2: Write migration content.** + +```python +def upgrade() -> None: + op.create_table( + 'l1_walk_sessions', + sa.Column('id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('account_id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('created_by_user_id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('acting_as', sa.String(30), nullable=True), + sa.Column('ticket_id', sa.String(64), nullable=False), + sa.Column('ticket_kind', sa.String(10), nullable=False), + sa.Column('session_kind', sa.String(20), nullable=False), + sa.Column('flow_id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('flow_proposal_id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('current_node_id', sa.String(100), nullable=True), + sa.Column('walked_path', sa.dialects.postgresql.JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")), + sa.Column('walk_notes', sa.dialects.postgresql.JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")), + sa.Column('status', sa.String(20), nullable=False, server_default='active'), + sa.Column('resolution_notes', sa.Text(), nullable=True), + sa.Column('helpful', sa.Boolean(), nullable=True), + sa.Column('escalation_reason', sa.Text(), nullable=True), + sa.Column('escalation_reason_category', sa.String(30), nullable=True), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), + sa.Column('last_step_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), + sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['flow_id'], ['trees.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['flow_proposal_id'], ['flow_proposals.id'], ondelete='SET NULL'), + sa.CheckConstraint( + "ticket_kind IN ('psa', 'internal')", + name='ck_l1_walk_sessions_ticket_kind', + ), + sa.CheckConstraint( + "session_kind IN ('flow', 'proposal', 'adhoc')", + name='ck_l1_walk_sessions_session_kind', + ), + sa.CheckConstraint( + "status IN ('active', 'resolved', 'escalated', 'abandoned')", + name='ck_l1_walk_sessions_status', + ), + sa.CheckConstraint( + "(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) " + "OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) " + "OR (session_kind = 'adhoc' AND flow_id IS NULL AND flow_proposal_id IS NULL)", + name='ck_l1_walk_sessions_target_consistency', + ), + ) + op.create_index('ix_l1_walk_sessions_account_id', 'l1_walk_sessions', ['account_id']) + op.create_index('ix_l1_walk_sessions_created_by_user_id', 'l1_walk_sessions', ['created_by_user_id']) + op.create_index('ix_l1_walk_sessions_status', 'l1_walk_sessions', ['status']) + op.create_index('ix_l1_walk_sessions_last_step_at', 'l1_walk_sessions', ['last_step_at']) + + op.execute("ALTER TABLE l1_walk_sessions ENABLE ROW LEVEL SECURITY") + op.execute(""" + CREATE POLICY l1_walk_sessions_account_isolation ON l1_walk_sessions + USING (account_id = current_setting('app.current_account_id')::uuid) + WITH CHECK (account_id = current_setting('app.current_account_id')::uuid) + """) + + +def downgrade() -> None: + op.execute("DROP POLICY IF EXISTS l1_walk_sessions_account_isolation ON l1_walk_sessions") + op.execute("ALTER TABLE l1_walk_sessions DISABLE ROW LEVEL SECURITY") + op.drop_index('ix_l1_walk_sessions_last_step_at', 'l1_walk_sessions') + op.drop_index('ix_l1_walk_sessions_status', 'l1_walk_sessions') + op.drop_index('ix_l1_walk_sessions_created_by_user_id', 'l1_walk_sessions') + op.drop_index('ix_l1_walk_sessions_account_id', 'l1_walk_sessions') + op.drop_table('l1_walk_sessions') +``` + +- [ ] **Step 3: Create the SQLAlchemy model.** + +Create `backend/app/models/l1_walk_session.py`: + +```python +import uuid +from datetime import datetime +from typing import Any, Optional + +import sqlalchemy as sa +from sqlalchemy import CheckConstraint, ForeignKey +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class L1WalkSession(Base): + __tablename__ = "l1_walk_sessions" + __table_args__ = ( + CheckConstraint( + "ticket_kind IN ('psa', 'internal')", + name="ck_l1_walk_sessions_ticket_kind", + ), + CheckConstraint( + "session_kind IN ('flow', 'proposal', 'adhoc')", + name="ck_l1_walk_sessions_session_kind", + ), + CheckConstraint( + "status IN ('active', 'resolved', 'escalated', 'abandoned')", + name="ck_l1_walk_sessions_status", + ), + CheckConstraint( + "(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) " + "OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) " + "OR (session_kind = 'adhoc' AND flow_id IS NULL AND flow_proposal_id IS NULL)", + name="ck_l1_walk_sessions_target_consistency", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("accounts.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + created_by_user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + acting_as: Mapped[Optional[str]] = mapped_column(sa.String(30), nullable=True) + ticket_id: Mapped[str] = mapped_column(sa.String(64), nullable=False) + ticket_kind: Mapped[str] = mapped_column(sa.String(10), nullable=False) + session_kind: Mapped[str] = mapped_column(sa.String(20), nullable=False) + flow_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("trees.id", ondelete="SET NULL"), nullable=True + ) + flow_proposal_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("flow_proposals.id", ondelete="SET NULL"), nullable=True + ) + current_node_id: Mapped[Optional[str]] = mapped_column(sa.String(100), nullable=True) + walked_path: Mapped[list[dict[str, Any]]] = mapped_column( + JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb") + ) + walk_notes: Mapped[list[dict[str, Any]]] = mapped_column( + JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb") + ) + status: Mapped[str] = mapped_column( + sa.String(20), nullable=False, server_default=sa.text("'active'"), index=True + ) + resolution_notes: Mapped[Optional[str]] = mapped_column(sa.Text(), nullable=True) + helpful: Mapped[Optional[bool]] = mapped_column(sa.Boolean(), nullable=True) + escalation_reason: Mapped[Optional[str]] = mapped_column(sa.Text(), nullable=True) + escalation_reason_category: Mapped[Optional[str]] = mapped_column(sa.String(30), nullable=True) + started_at: Mapped[datetime] = mapped_column( + sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()") + ) + last_step_at: Mapped[datetime] = mapped_column( + sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), index=True + ) + resolved_at: Mapped[Optional[datetime]] = mapped_column(sa.DateTime(timezone=True), nullable=True) +``` + +- [ ] **Step 4: Apply migration + verify RLS.** + +```bash +docker exec -w /app resolutionflow_backend alembic upgrade head +docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow -c "SELECT polname FROM pg_policy WHERE polrelid = 'l1_walk_sessions'::regclass" +``` + +- [ ] **Step 5: Verify check constraint blocks invalid combos.** + +```bash +docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow -c "INSERT INTO l1_walk_sessions (id, account_id, created_by_user_id, ticket_id, ticket_kind, session_kind) VALUES (gen_random_uuid(), '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', 'x', 'psa', 'flow')" +``` +Expected: violates `ck_l1_walk_sessions_target_consistency` (flow_id NULL). + +- [ ] **Step 6: Commit.** + +```bash +git add backend/alembic/versions/_create_l1_walk_sessions.py backend/app/models/l1_walk_session.py +git commit -m "feat(l1): create l1_walk_sessions table with RLS + target-consistency check" +``` + +--- + +## Task 7: Seat enforcement service + +**Files:** +- Create: `backend/app/schemas/seat_enforcement.py` +- Create: `backend/app/services/seat_enforcement.py` +- Test: `backend/tests/test_seat_enforcement.py` + +- [ ] **Step 1: Write the failing tests.** + +Create `backend/tests/test_seat_enforcement.py`: + +```python +import pytest +from app.services.seat_enforcement import check_seat_available +from tests.factories import make_account, make_subscription, make_user + + +@pytest.mark.asyncio +async def test_engineer_seat_available_when_under_limit(db_session): + account = await make_account(db_session) + sub = await make_subscription(db_session, account_id=account.id, seat_limit=5) + for _ in range(3): + await make_user(db_session, account_id=account.id, account_role='engineer', is_active=True) + result = await check_seat_available(account, sub, 'engineer', db_session) + assert result.available is True + assert result.current == 3 + assert result.limit == 5 + + +@pytest.mark.asyncio +async def test_engineer_seat_unavailable_when_at_limit(db_session): + account = await make_account(db_session) + sub = await make_subscription(db_session, account_id=account.id, seat_limit=2) + for _ in range(2): + await make_user(db_session, account_id=account.id, account_role='engineer', is_active=True) + result = await check_seat_available(account, sub, 'engineer', db_session) + assert result.available is False + assert result.current == 2 + assert result.limit == 2 + + +@pytest.mark.asyncio +async def test_l1_seat_uses_separate_limit(db_session): + account = await make_account(db_session) + sub = await make_subscription(db_session, account_id=account.id, seat_limit=2, l1_seat_limit=10) + for _ in range(2): + await make_user(db_session, account_id=account.id, account_role='engineer', is_active=True) + # Engineer limit hit but L1 seats still available + eng_result = await check_seat_available(account, sub, 'engineer', db_session) + l1_result = await check_seat_available(account, sub, 'l1_tech', db_session) + assert eng_result.available is False + assert l1_result.available is True + assert l1_result.limit == 10 + + +@pytest.mark.asyncio +async def test_unlimited_when_limit_null(db_session): + account = await make_account(db_session) + sub = await make_subscription(db_session, account_id=account.id, seat_limit=None) + for _ in range(100): + await make_user(db_session, account_id=account.id, account_role='engineer', is_active=True) + result = await check_seat_available(account, sub, 'engineer', db_session) + assert result.available is True + assert result.limit is None +``` + +- [ ] **Step 2: Run tests, verify failure.** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_seat_enforcement.py -v +``` +Expected: ImportError. + +- [ ] **Step 3: Create schema.** + +`backend/app/schemas/seat_enforcement.py`: + +```python +from typing import Literal, Optional + +from pydantic import BaseModel + +Role = Literal['engineer', 'l1_tech'] + + +class SeatCheckResult(BaseModel): + available: bool + current: int + limit: Optional[int] # None = unlimited + role: Role + + +class SeatUsage(BaseModel): + engineer: SeatCheckResult + l1_tech: SeatCheckResult +``` + +- [ ] **Step 4: Create service.** + +`backend/app/services/seat_enforcement.py`: + +```python +from typing import Literal + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.account import Account +from app.models.subscription import Subscription +from app.models.user import User +from app.schemas.seat_enforcement import SeatCheckResult + + +Role = Literal['engineer', 'l1_tech'] + + +def _limit_for_role(subscription: Subscription, role: Role) -> int | None: + if role == 'engineer': + return subscription.seat_limit + if role == 'l1_tech': + return subscription.l1_seat_limit + raise ValueError(f"Unknown role: {role}") + + +async def check_seat_available( + account: Account, + subscription: Subscription, + role: Role, + db: AsyncSession, +) -> SeatCheckResult: + """ + Count active users with the given role in the account, compare against + the role-specific seat limit on the subscription. Returns availability. + + None limit = unlimited (returns available=True). + """ + limit = _limit_for_role(subscription, role) + + stmt = ( + select(func.count(User.id)) + .where(User.account_id == account.id) + .where(User.account_role == role) + .where(User.is_active.is_(True)) + ) + current = (await db.execute(stmt)).scalar_one() + + if limit is None: + return SeatCheckResult(available=True, current=current, limit=None, role=role) + return SeatCheckResult( + available=current < limit, + current=current, + limit=limit, + role=role, + ) + + +async def get_seat_usage( + account: Account, + subscription: Subscription, + db: AsyncSession, +) -> tuple[SeatCheckResult, SeatCheckResult]: + """Return (engineer, l1_tech) seat-usage tuple for the seat-counter widget.""" + eng = await check_seat_available(account, subscription, 'engineer', db) + l1 = await check_seat_available(account, subscription, 'l1_tech', db) + return eng, l1 +``` + +- [ ] **Step 5: Run tests, verify pass.** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_seat_enforcement.py -v +``` +Expected: 4 PASSED. + +- [ ] **Step 6: Commit.** + +```bash +git add backend/app/schemas/seat_enforcement.py backend/app/services/seat_enforcement.py backend/tests/test_seat_enforcement.py +git commit -m "feat(l1): add seat_enforcement service for engineer + L1 seat limits" +``` + +--- + +## Task 8: Integrate seat enforcement into invite + accept-invite + role change + +**Files:** +- Modify: `backend/app/api/endpoints/invite.py` — block invite create when limit reached +- Modify: `backend/app/api/endpoints/accounts.py` or wherever accept-invite lives — re-check at accept time +- Modify: wherever role-change PATCH lives (likely `accounts.py` or `admin.py`) — re-check before commit +- Test: extend `backend/tests/test_seat_enforcement.py` + +- [ ] **Step 1: Write failing integration tests.** + +Add to `backend/tests/test_seat_enforcement.py`: + +```python +@pytest.mark.asyncio +async def test_invite_blocked_when_engineer_seats_full(authed_owner_client, db_session): + # Setup: account at engineer seat limit + # ... (use existing test fixtures to seed an account with N=limit engineers) + response = await authed_owner_client.post( + "/api/v1/invites", + json={"email": "new@example.com", "role": "engineer"}, + ) + assert response.status_code == 402 + body = response.json() + assert body["detail"]["code"] == "seat_limit_exceeded" + assert body["detail"]["role"] == "engineer" + assert body["detail"]["current"] == body["detail"]["limit"] + + +@pytest.mark.asyncio +async def test_invite_blocked_when_l1_seats_full(authed_owner_client, db_session): + # Same as above but for l1_tech role + response = await authed_owner_client.post( + "/api/v1/invites", + json={"email": "new@example.com", "role": "l1_tech"}, + ) + assert response.status_code == 402 + + +@pytest.mark.asyncio +async def test_invite_succeeds_when_l1_seats_available(authed_owner_client, db_session): + # Account with l1_seat_limit=10, current L1 count = 0 + response = await authed_owner_client.post( + "/api/v1/invites", + json={"email": "new@example.com", "role": "l1_tech"}, + ) + assert response.status_code == 201 + + +@pytest.mark.asyncio +async def test_role_change_blocked_when_target_seats_full(authed_owner_client, viewer_user, db_session): + # Try promoting viewer → engineer when engineer seats are full + response = await authed_owner_client.patch( + f"/api/v1/users/{viewer_user.id}/role", + json={"account_role": "engineer"}, + ) + assert response.status_code == 402 +``` + +- [ ] **Step 2: Run, verify failure.** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_seat_enforcement.py::test_invite_blocked_when_engineer_seats_full -v +``` + +- [ ] **Step 3: Add seat check to invite create endpoint.** + +In `backend/app/api/endpoints/invite.py`, locate the create endpoint and add at the top (before persistence): + +```python +from app.services.seat_enforcement import check_seat_available + +# ... inside the create handler ... +if payload.role in ('engineer', 'l1_tech'): + # Load the account's subscription (existing pattern in the codebase) + sub = await get_active_subscription(db, current_user.account_id) + result = await check_seat_available(account, sub, payload.role, db) + if not result.available: + raise HTTPException( + status_code=402, + detail={ + "code": "seat_limit_exceeded", + "role": result.role, + "current": result.current, + "limit": result.limit, + "upgrade_url": "/account/billing", # Frontend deep-links to the existing /account/billing page; Stripe customer portal link is generated server-side there. + }, + ) +``` + +(If the codebase has an existing helper like `get_active_subscription`, use it. If not, add a thin helper in `services/billing.py`.) + +- [ ] **Step 4: Add check to accept-invite endpoint.** + +Find the accept-invite endpoint (`grep -rn "accept.invite\|@router.post.*accept" backend/app/api/endpoints/`). Add the same `check_seat_available` call before the user is upgraded from invite to active user. Race-condition guard. + +- [ ] **Step 5: Add check to role-change endpoint.** + +Find the role-change PATCH endpoint. If promoting toward `engineer` or `l1_tech`, run the check first. Return same 402 shape on block. + +- [ ] **Step 6: Run integration tests, verify pass.** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_seat_enforcement.py -v +``` + +- [ ] **Step 7: Commit.** + +```bash +git add backend/app/api/endpoints/invite.py backend/app/api/endpoints/accounts.py backend/tests/test_seat_enforcement.py +git commit -m "feat(l1): enforce seat limits on invite, accept-invite, role-change for engineer + L1" +``` + +--- + +## Task 9: `GET /api/v1/accounts/me/seats` endpoint + +**Files:** +- Modify: `backend/app/api/endpoints/accounts.py` (add endpoint) +- Test: `backend/tests/test_l1_endpoints.py` (new) + +- [ ] **Step 1: Write failing test.** + +Create `backend/tests/test_l1_endpoints.py`: + +```python +import pytest + + +@pytest.mark.asyncio +async def test_get_seats_returns_both_role_counts(authed_engineer_client, db_session): + response = await authed_engineer_client.get("/api/v1/accounts/me/seats") + assert response.status_code == 200 + body = response.json() + assert "engineer" in body + assert "l1_tech" in body + assert {"available", "current", "limit", "role"}.issubset(body["engineer"].keys()) + + +@pytest.mark.asyncio +async def test_get_seats_blocked_for_viewer(authed_viewer_client): + response = await authed_viewer_client.get("/api/v1/accounts/me/seats") + assert response.status_code == 403 +``` + +- [ ] **Step 2: Run, verify failure.** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_l1_endpoints.py::test_get_seats_returns_both_role_counts -v +``` + +- [ ] **Step 3: Add endpoint to `accounts.py`.** + +```python +from app.services.seat_enforcement import get_seat_usage +from app.schemas.seat_enforcement import SeatUsage + +@router.get("/me/seats", response_model=SeatUsage) +async def get_my_account_seat_usage( + current_user: User = Depends(require_engineer_or_admin), + db: AsyncSession = Depends(get_db), +): + account = await db.get(Account, current_user.account_id) + sub = await get_active_subscription(db, current_user.account_id) + engineer, l1_tech = await get_seat_usage(account, sub, db) + return SeatUsage(engineer=engineer, l1_tech=l1_tech) +``` + +- [ ] **Step 4: Run tests, verify pass.** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_l1_endpoints.py -v +``` + +- [ ] **Step 5: Commit.** + +```bash +git add backend/app/api/endpoints/accounts.py backend/tests/test_l1_endpoints.py +git commit -m "feat(l1): GET /accounts/me/seats endpoint" +``` + +--- + +## Task 10: `PATCH /api/v1/users/{id}/coverage` endpoint + +**Files:** +- Modify: `backend/app/api/endpoints/accounts.py` (or wherever user-management endpoints live) +- Test: extend `backend/tests/test_l1_endpoints.py` + +- [ ] **Step 1: Write failing tests.** + +```python +@pytest.mark.asyncio +async def test_owner_can_toggle_coverage_on_engineer(authed_owner_client, engineer_user): + response = await authed_owner_client.patch( + f"/api/v1/users/{engineer_user.id}/coverage", + json={"can_cover_l1": True}, + ) + assert response.status_code == 200 + assert response.json()["can_cover_l1"] is True + + +@pytest.mark.asyncio +async def test_engineer_cannot_toggle_coverage_on_self(authed_engineer_client, engineer_user): + response = await authed_engineer_client.patch( + f"/api/v1/users/{engineer_user.id}/coverage", + json={"can_cover_l1": True}, + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_coverage_only_applies_to_engineers(authed_owner_client, viewer_user): + response = await authed_owner_client.patch( + f"/api/v1/users/{viewer_user.id}/coverage", + json={"can_cover_l1": True}, + ) + assert response.status_code == 422 # validation: viewer can't have coverage +``` + +- [ ] **Step 2: Run, verify failure.** + +- [ ] **Step 3: Add endpoint.** + +```python +from pydantic import BaseModel + +class CoveragePatch(BaseModel): + can_cover_l1: bool + +@router.patch("/users/{user_id}/coverage") +async def patch_user_coverage( + user_id: UUID, + payload: CoveragePatch, + current_user: User = Depends(require_account_owner), + db: AsyncSession = Depends(get_db), +): + target = await db.get(User, user_id) + if not target or target.account_id != current_user.account_id: + raise HTTPException(status_code=404) + if target.account_role != 'engineer': + raise HTTPException( + status_code=422, + detail="can_cover_l1 only applies to engineers", + ) + target.can_cover_l1 = payload.can_cover_l1 + await db.commit() + return {"id": str(target.id), "can_cover_l1": target.can_cover_l1} +``` + +- [ ] **Step 4: Run tests, verify pass.** + +- [ ] **Step 5: Commit.** + +```bash +git add backend/app/api/endpoints/accounts.py backend/tests/test_l1_endpoints.py +git commit -m "feat(l1): PATCH /users/{id}/coverage for engineer L1-coverage flag" +``` + +--- + +## Task 11: `internal_ticket_service.py` + +**Files:** +- Create: `backend/app/services/internal_ticket_service.py` +- Test: `backend/tests/test_internal_ticket_service.py` + +- [ ] **Step 1: Write failing tests.** + +```python +import pytest +import uuid +from app.services.internal_ticket_service import ( + create_ticket, update_status, get_ticket, list_tickets_for_account, +) + + +@pytest.mark.asyncio +async def test_create_ticket_sets_status_open(db_session, account, l1_user): + ticket = await create_ticket( + db_session, + account_id=account.id, + created_by_user_id=l1_user.id, + problem_statement="Outlook can't connect", + customer_name="Alice", + ) + assert ticket.status == 'open' + assert ticket.account_id == account.id + + +@pytest.mark.asyncio +async def test_update_status_to_resolved_sets_resolved_at(db_session, internal_ticket): + updated = await update_status(db_session, ticket_id=internal_ticket.id, status='resolved') + assert updated.status == 'resolved' + assert updated.resolved_at is not None + + +@pytest.mark.asyncio +async def test_list_tickets_filters_by_account(db_session, account_a, account_b, ticket_a, ticket_b): + rows = await list_tickets_for_account(db_session, account_id=account_a.id) + assert ticket_a in rows + assert ticket_b not in rows +``` + +- [ ] **Step 2: Run, verify failure.** + +- [ ] **Step 3: Implement service.** + +```python +from datetime import datetime, timezone +from typing import Optional +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.internal_ticket import InternalTicket + + +async def create_ticket( + db: AsyncSession, + *, + account_id: UUID, + created_by_user_id: UUID, + problem_statement: str, + customer_name: Optional[str] = None, + customer_contact: Optional[str] = None, +) -> InternalTicket: + ticket = InternalTicket( + account_id=account_id, + created_by_user_id=created_by_user_id, + problem_statement=problem_statement, + customer_name=customer_name, + customer_contact=customer_contact, + ) + db.add(ticket) + await db.flush() + return ticket + + +async def update_status( + db: AsyncSession, + *, + ticket_id: UUID, + status: str, + resolution_notes: Optional[str] = None, + assigned_user_id: Optional[UUID] = None, +) -> InternalTicket: + ticket = await db.get(InternalTicket, ticket_id) + if not ticket: + raise ValueError(f"InternalTicket {ticket_id} not found") + ticket.status = status + if status == 'resolved': + ticket.resolved_at = datetime.now(timezone.utc) + if resolution_notes is not None: + ticket.resolution_notes = resolution_notes + if assigned_user_id is not None: + ticket.assigned_user_id = assigned_user_id + await db.flush() + return ticket + + +async def get_ticket(db: AsyncSession, ticket_id: UUID) -> Optional[InternalTicket]: + return await db.get(InternalTicket, ticket_id) + + +async def list_tickets_for_account( + db: AsyncSession, + *, + account_id: UUID, + status: Optional[str] = None, + limit: int = 100, +) -> list[InternalTicket]: + stmt = select(InternalTicket).where(InternalTicket.account_id == account_id) + if status: + stmt = stmt.where(InternalTicket.status == status) + stmt = stmt.order_by(InternalTicket.created_at.desc()).limit(limit) + result = await db.execute(stmt) + return list(result.scalars()) + + +async def promote_to_psa( + db: AsyncSession, + *, + ticket_id: UUID, + psa_ticket_id: str, +) -> InternalTicket: + ticket = await db.get(InternalTicket, ticket_id) + if not ticket: + raise ValueError(f"InternalTicket {ticket_id} not found") + ticket.psa_promoted_ticket_id = psa_ticket_id + await db.flush() + return ticket +``` + +- [ ] **Step 4: Run, verify pass.** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_internal_ticket_service.py -v +``` + +- [ ] **Step 5: Commit.** + +```bash +git add backend/app/services/internal_ticket_service.py backend/tests/test_internal_ticket_service.py +git commit -m "feat(l1): internal_ticket_service with CRUD + status transitions" +``` + +--- + +## Task 12: `l1_session_service.py` — start (flow / proposal / adhoc) + +**Files:** +- Create: `backend/app/services/l1_session_service.py` +- Test: `backend/tests/test_l1_session_service.py` + +This task implements the session start path only. Steps/notes/resolve/escalate are split into Tasks 13/14 to keep each task bite-sized. + +- [ ] **Step 1: Write failing tests.** + +```python +import pytest +from app.services.l1_session_service import start_flow_session, start_adhoc_session + + +@pytest.mark.asyncio +async def test_start_flow_session_creates_active_session(db_session, account, l1_user, flow, internal_ticket): + session = await start_flow_session( + db_session, + account_id=account.id, + user=l1_user, + flow_id=flow.id, + ticket_id=str(internal_ticket.id), + ticket_kind='internal', + ) + assert session.session_kind == 'flow' + assert session.flow_id == flow.id + assert session.flow_proposal_id is None + assert session.status == 'active' + assert session.walked_path == [] + + +@pytest.mark.asyncio +async def test_start_adhoc_session_no_flow(db_session, account, l1_user, internal_ticket): + session = await start_adhoc_session( + db_session, + account_id=account.id, + user=l1_user, + ticket_id=str(internal_ticket.id), + ticket_kind='internal', + ) + assert session.session_kind == 'adhoc' + assert session.flow_id is None + assert session.flow_proposal_id is None + assert session.walked_path == [] + assert session.walk_notes == [] + + +@pytest.mark.asyncio +async def test_start_session_records_acting_as_for_coverage_engineer( + db_session, account, engineer_with_coverage, flow, internal_ticket +): + session = await start_flow_session( + db_session, + account_id=account.id, + user=engineer_with_coverage, + flow_id=flow.id, + ticket_id=str(internal_ticket.id), + ticket_kind='internal', + ) + assert session.acting_as == 'l1_coverage' +``` + +- [ ] **Step 2: Run, verify failure.** + +- [ ] **Step 3: Implement start functions.** + +```python +from typing import Optional +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.l1_walk_session import L1WalkSession +from app.models.user import User + + +def _resolve_acting_as(user: User) -> Optional[str]: + """An engineer (with coverage flag) acting in L1 mode gets tagged for audit.""" + if user.account_role == 'engineer': + return 'l1_coverage' + return None + + +async def start_flow_session( + db: AsyncSession, + *, + account_id: UUID, + user: User, + flow_id: UUID, + ticket_id: str, + ticket_kind: str, # 'psa' | 'internal' +) -> L1WalkSession: + session = L1WalkSession( + account_id=account_id, + created_by_user_id=user.id, + acting_as=_resolve_acting_as(user), + ticket_id=ticket_id, + ticket_kind=ticket_kind, + session_kind='flow', + flow_id=flow_id, + walked_path=[], + walk_notes=[], + ) + db.add(session) + await db.flush() + return session + + +async def start_proposal_session( + db: AsyncSession, + *, + account_id: UUID, + user: User, + flow_proposal_id: UUID, + ticket_id: str, + ticket_kind: str, +) -> L1WalkSession: + session = L1WalkSession( + account_id=account_id, + created_by_user_id=user.id, + acting_as=_resolve_acting_as(user), + ticket_id=ticket_id, + ticket_kind=ticket_kind, + session_kind='proposal', + flow_proposal_id=flow_proposal_id, + walked_path=[], + walk_notes=[], + ) + db.add(session) + await db.flush() + return session + + +async def start_adhoc_session( + db: AsyncSession, + *, + account_id: UUID, + user: User, + ticket_id: str, + ticket_kind: str, +) -> L1WalkSession: + session = L1WalkSession( + account_id=account_id, + created_by_user_id=user.id, + acting_as=_resolve_acting_as(user), + ticket_id=ticket_id, + ticket_kind=ticket_kind, + session_kind='adhoc', + walked_path=[], + walk_notes=[], + ) + db.add(session) + await db.flush() + return session +``` + +- [ ] **Step 4: Run, verify pass.** + +- [ ] **Step 5: Commit.** + +```bash +git add backend/app/services/l1_session_service.py backend/tests/test_l1_session_service.py +git commit -m "feat(l1): session start (flow/proposal/adhoc) with acting_as tagging" +``` + +--- + +## Task 13: `l1_session_service.py` — step + notes + +**Files:** +- Modify: `backend/app/services/l1_session_service.py` +- Modify: `backend/tests/test_l1_session_service.py` + +- [ ] **Step 1: Write failing tests (append to existing file).** + +```python +@pytest.mark.asyncio +async def test_record_step_appends_to_walked_path(db_session, active_flow_session): + updated = await record_step( + db_session, + session_id=active_flow_session.id, + node_id='n1', + question='Has the user signed back in?', + answer='yes', + note=None, + ) + assert len(updated.walked_path) == 1 + assert updated.walked_path[0] == { + 'node_id': 'n1', + 'question': 'Has the user signed back in?', + 'answer': 'yes', + 'l1_note': None, + } + assert updated.current_node_id == 'n1' + + +@pytest.mark.asyncio +async def test_record_step_blocks_on_adhoc_session(db_session, active_adhoc_session): + with pytest.raises(ValueError, match="adhoc"): + await record_step( + db_session, + session_id=active_adhoc_session.id, + node_id='n1', question='x', answer='y', note=None, + ) + + +@pytest.mark.asyncio +async def test_update_notes_replaces_walk_notes(db_session, active_adhoc_session): + new_notes = [{'timestamp': '2026-05-28T10:00:00Z', 'content': 'Customer said outlook crashed'}] + updated = await update_notes(db_session, session_id=active_adhoc_session.id, notes=new_notes) + assert updated.walk_notes == new_notes +``` + +- [ ] **Step 2: Run, verify failure.** + +- [ ] **Step 3: Implement.** + +Append to `l1_session_service.py`: + +```python +from datetime import datetime, timezone + + +async def record_step( + db: AsyncSession, + *, + session_id: UUID, + node_id: str, + question: str, + answer: str, + note: Optional[str], +) -> L1WalkSession: + session = await db.get(L1WalkSession, session_id) + if not session: + raise ValueError(f"L1WalkSession {session_id} not found") + if session.session_kind == 'adhoc': + raise ValueError("Cannot record step on adhoc session — use update_notes instead") + if session.status != 'active': + raise ValueError(f"Session {session_id} is not active (status={session.status})") + entry = { + 'node_id': node_id, + 'question': question, + 'answer': answer, + 'l1_note': note, + } + # JSONB append — assign new list because SQLAlchemy doesn't track in-place mutations + session.walked_path = [*session.walked_path, entry] + session.current_node_id = node_id + session.last_step_at = datetime.now(timezone.utc) + await db.flush() + return session + + +async def update_notes( + db: AsyncSession, + *, + session_id: UUID, + notes: list[dict], +) -> L1WalkSession: + session = await db.get(L1WalkSession, session_id) + if not session: + raise ValueError(f"L1WalkSession {session_id} not found") + # Cap at 256KB (rough check, JSON-encoded size) + import json + encoded_size = len(json.dumps(notes).encode('utf-8')) + if encoded_size > 256 * 1024: + raise ValueError("walk_notes exceeds 256KB cap — consider escalating") + session.walk_notes = notes + session.last_step_at = datetime.now(timezone.utc) + await db.flush() + return session +``` + +- [ ] **Step 4: Run, verify pass.** + +- [ ] **Step 5: Commit.** + +```bash +git add backend/app/services/l1_session_service.py backend/tests/test_l1_session_service.py +git commit -m "feat(l1): record_step + update_notes for walk sessions" +``` + +--- + +## Task 14: `l1_session_service.py` — resolve / escalate / escalate-without-walk + +**Files:** +- Modify: `backend/app/services/l1_session_service.py` +- Modify: `backend/tests/test_l1_session_service.py` + +- [ ] **Step 1: Write failing tests.** + +```python +@pytest.mark.asyncio +async def test_resolve_helpful_proposal_flips_validated_by_outcome( + db_session, active_proposal_session, flow_proposal +): + await resolve( + db_session, + session_id=active_proposal_session.id, + helpful=True, + resolution_notes="Walked the user through restoring profile", + ) + await db_session.refresh(flow_proposal) + assert flow_proposal.validated_by_outcome is True + + +@pytest.mark.asyncio +async def test_resolve_unhelpful_does_not_flip_validation( + db_session, active_proposal_session, flow_proposal +): + await resolve( + db_session, + session_id=active_proposal_session.id, + helpful=False, + resolution_notes="Tree was wrong", + ) + await db_session.refresh(flow_proposal) + assert flow_proposal.validated_by_outcome is False + + +@pytest.mark.asyncio +async def test_resolve_adhoc_session_closes_ticket( + db_session, active_adhoc_session, internal_ticket +): + await resolve( + db_session, + session_id=active_adhoc_session.id, + helpful=True, + resolution_notes="Customer rebooted, fixed", + ) + await db_session.refresh(active_adhoc_session) + assert active_adhoc_session.status == 'resolved' + assert active_adhoc_session.resolved_at is not None + await db_session.refresh(internal_ticket) + assert internal_ticket.status == 'resolved' + + +@pytest.mark.asyncio +async def test_escalate_marks_session_and_ticket( + db_session, active_flow_session, internal_ticket +): + await escalate( + db_session, + session_id=active_flow_session.id, + reason="Customer demanding senior", + reason_category="Customer demanding senior", + ) + await db_session.refresh(active_flow_session) + assert active_flow_session.status == 'escalated' + await db_session.refresh(internal_ticket) + assert internal_ticket.status == 'escalated' + + +@pytest.mark.asyncio +async def test_escalate_without_walk_creates_escalated_session( + db_session, account, l1_user, internal_ticket +): + session = await escalate_without_walk( + db_session, + account_id=account.id, + user=l1_user, + ticket_id=str(internal_ticket.id), + ticket_kind='internal', + reason_category='No KB available', + reason='No knowledge base content yet', + ) + assert session.status == 'escalated' + assert session.session_kind == 'adhoc' # adhoc as placeholder kind + assert session.escalation_reason_category == 'No KB available' +``` + +- [ ] **Step 2: Run, verify failure.** + +- [ ] **Step 3: Implement.** + +Append to `l1_session_service.py`: + +```python +from app.models.flow_proposal import FlowProposal +from app.services import internal_ticket_service +# PSA service import — adjust to actual path +# from app.services.psa.registry import PsaProviderRegistry + + +async def resolve( + db: AsyncSession, + *, + session_id: UUID, + helpful: bool, + resolution_notes: str, +) -> L1WalkSession: + session = await db.get(L1WalkSession, session_id) + if not session: + raise ValueError(f"L1WalkSession {session_id} not found") + if session.status != 'active': + raise ValueError(f"Session not active (status={session.status})") + session.status = 'resolved' + session.helpful = helpful + session.resolution_notes = resolution_notes + session.resolved_at = datetime.now(timezone.utc) + session.last_step_at = session.resolved_at + + # Outcome validation: flip proposal flag on helpful=true + if helpful and session.session_kind == 'proposal' and session.flow_proposal_id: + proposal = await db.get(FlowProposal, session.flow_proposal_id) + if proposal: + proposal.validated_by_outcome = True + + # Close the ticket + if session.ticket_kind == 'internal': + await internal_ticket_service.update_status( + db, ticket_id=UUID(session.ticket_id), + status='resolved', resolution_notes=resolution_notes, + ) + else: + # PSA close is intentionally deferred to Phase 2 (which adds the full escalation_package_generator + # integration alongside the AI build pipeline). For Phase 1, PSA-backed sessions update only the + # local session row on resolve; engineers verify ticket state directly in their PSA UI. + # This is documented in spec §11 "Internal ticket fallback" and the Phase 1 scope section. + pass + + await db.flush() + return session + + +async def escalate( + db: AsyncSession, + *, + session_id: UUID, + reason: str, + reason_category: str, +) -> L1WalkSession: + session = await db.get(L1WalkSession, session_id) + if not session: + raise ValueError(f"L1WalkSession {session_id} not found") + if session.status != 'active': + raise ValueError(f"Session not active (status={session.status})") + session.status = 'escalated' + session.escalation_reason = reason + session.escalation_reason_category = reason_category + session.resolved_at = datetime.now(timezone.utc) + session.last_step_at = session.resolved_at + + # Mark ticket escalated + if session.ticket_kind == 'internal': + await internal_ticket_service.update_status( + db, ticket_id=UUID(session.ticket_id), status='escalated', + ) + else: + # PSA reassign — Phase 1 stub; full integration with escalation_package_generator + # follows in Phase 2 alongside the ai_session creation for engineer pickup. + pass + + await db.flush() + return session + + +async def escalate_without_walk( + db: AsyncSession, + *, + account_id: UUID, + user: User, + ticket_id: str, + ticket_kind: str, + reason_category: str, + reason: Optional[str] = None, +) -> L1WalkSession: + """ + Used from the no-KB / no-flow-picked screen — creates an immediately-escalated session + with no walked_path. Lets escalation reporting still capture the call. + """ + session = L1WalkSession( + account_id=account_id, + created_by_user_id=user.id, + acting_as=_resolve_acting_as(user), + ticket_id=ticket_id, + ticket_kind=ticket_kind, + session_kind='adhoc', + walked_path=[], + walk_notes=[], + status='escalated', + escalation_reason=reason, + escalation_reason_category=reason_category, + resolved_at=datetime.now(timezone.utc), + ) + session.last_step_at = session.resolved_at + db.add(session) + if ticket_kind == 'internal': + await internal_ticket_service.update_status( + db, ticket_id=UUID(ticket_id), status='escalated', + ) + await db.flush() + return session +``` + +- [ ] **Step 4: Run, verify pass.** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_l1_session_service.py -v +``` + +- [ ] **Step 5: Commit.** + +```bash +git add backend/app/services/l1_session_service.py backend/tests/test_l1_session_service.py +git commit -m "feat(l1): resolve/escalate/escalate-without-walk for l1 sessions" +``` + +--- + +## Task 15: L1 endpoints (`api/endpoints/l1.py`) + +**Files:** +- Create: `backend/app/schemas/l1.py` +- Create: `backend/app/api/endpoints/l1.py` +- Modify: `backend/app/api/router.py` (register `l1` router) +- Modify: `backend/tests/test_l1_endpoints.py` + +- [ ] **Step 1: Define request/response schemas.** + +`backend/app/schemas/l1.py`: + +```python +from datetime import datetime +from typing import Any, Literal, Optional +from uuid import UUID + +from pydantic import BaseModel + + +class IntakeRequest(BaseModel): + problem_statement: str + customer_name: Optional[str] = None + customer_contact: Optional[str] = None + flow_id: Optional[UUID] = None # if provided, starts flow session; else adhoc + + +class IntakeResponse(BaseModel): + session_id: UUID + session_kind: Literal['flow', 'proposal', 'adhoc'] + ticket_id: str + ticket_kind: Literal['psa', 'internal'] + + +class StepRequest(BaseModel): + node_id: str + question: str + answer: str + note: Optional[str] = None + + +class NotesRequest(BaseModel): + notes: list[dict[str, Any]] + + +class ResolveRequest(BaseModel): + helpful: bool + resolution_notes: str + + +class EscalateRequest(BaseModel): + reason: Optional[str] = None + reason_category: str + + +class EscalateWithoutWalkRequest(BaseModel): + problem_statement: str + customer_name: Optional[str] = None + customer_contact: Optional[str] = None + reason_category: str + reason: Optional[str] = None + + +class WalkSessionResponse(BaseModel): + id: UUID + session_kind: str + flow_id: Optional[UUID] + flow_proposal_id: Optional[UUID] + current_node_id: Optional[str] + walked_path: list[dict[str, Any]] + walk_notes: list[dict[str, Any]] + status: str + started_at: datetime + last_step_at: datetime + resolved_at: Optional[datetime] + + +class QueueRow(BaseModel): + ticket_id: str + ticket_kind: Literal['psa', 'internal'] + problem_statement: Optional[str] + customer_name: Optional[str] + status: str + created_at: Optional[datetime] +``` + +- [ ] **Step 2: Write the endpoints.** + +`backend/app/api/endpoints/l1.py`: + +```python +from typing import Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import ( + get_current_active_user, get_db, require_l1_or_coverage, +) +from app.models.l1_walk_session import L1WalkSession +from app.models.user import User +from app.schemas.l1 import ( + EscalateRequest, EscalateWithoutWalkRequest, IntakeRequest, IntakeResponse, + NotesRequest, QueueRow, ResolveRequest, StepRequest, WalkSessionResponse, +) +from app.services import internal_ticket_service, l1_session_service + + +router = APIRouter(prefix="/l1", tags=["l1"]) + + +def _as_response(session: L1WalkSession) -> WalkSessionResponse: + return WalkSessionResponse( + id=session.id, + session_kind=session.session_kind, + flow_id=session.flow_id, + flow_proposal_id=session.flow_proposal_id, + current_node_id=session.current_node_id, + walked_path=session.walked_path, + walk_notes=session.walk_notes, + status=session.status, + started_at=session.started_at, + last_step_at=session.last_step_at, + resolved_at=session.resolved_at, + ) + + +@router.post("/intake", response_model=IntakeResponse) +async def intake( + payload: IntakeRequest, + user: User = Depends(require_l1_or_coverage), + db: AsyncSession = Depends(get_db), +): + """ + Phase 1 intake: + - Creates an internal ticket (PSA integration deferred to Phase 2/3 escalation polish). + - Starts an L1WalkSession: flow kind if flow_id provided, adhoc otherwise. + + Phase 2 will replace this with match_or_build + suggest/aborted_no_kb outcomes. + """ + # Phase 1: always create internal ticket. PSA support handled in escalate() / escalation_package. + ticket = await internal_ticket_service.create_ticket( + db, + account_id=user.account_id, + created_by_user_id=user.id, + problem_statement=payload.problem_statement, + customer_name=payload.customer_name, + customer_contact=payload.customer_contact, + ) + + if payload.flow_id: + session = await l1_session_service.start_flow_session( + db, + account_id=user.account_id, + user=user, + flow_id=payload.flow_id, + ticket_id=str(ticket.id), + ticket_kind='internal', + ) + else: + session = await l1_session_service.start_adhoc_session( + db, + account_id=user.account_id, + user=user, + ticket_id=str(ticket.id), + ticket_kind='internal', + ) + + await db.commit() + return IntakeResponse( + session_id=session.id, + session_kind=session.session_kind, + ticket_id=str(ticket.id), + ticket_kind='internal', + ) + + +@router.get("/queue", response_model=list[QueueRow]) +async def queue( + status_filter: Optional[str] = None, + limit: int = 50, + user: User = Depends(require_l1_or_coverage), + db: AsyncSession = Depends(get_db), +): + """Phase 1: returns internal tickets only. PSA queue merge in Phase 2.""" + tickets = await internal_ticket_service.list_tickets_for_account( + db, account_id=user.account_id, status=status_filter, limit=limit, + ) + return [ + QueueRow( + ticket_id=str(t.id), + ticket_kind='internal', + problem_statement=t.problem_statement, + customer_name=t.customer_name, + status=t.status, + created_at=t.created_at, + ) + for t in tickets + ] + + +@router.get("/sessions/{session_id}", response_model=WalkSessionResponse) +async def get_session( + session_id: UUID, + user: User = Depends(require_l1_or_coverage), + db: AsyncSession = Depends(get_db), +): + session = await db.get(L1WalkSession, session_id) + if not session or session.account_id != user.account_id: + raise HTTPException(status_code=404) + return _as_response(session) + + +@router.get("/sessions/active", response_model=list[WalkSessionResponse]) +async def list_active_sessions( + user: User = Depends(require_l1_or_coverage), + db: AsyncSession = Depends(get_db), +): + """List the caller's currently-active sessions for the dashboard 'Resume in progress' widget.""" + stmt = ( + select(L1WalkSession) + .where(L1WalkSession.created_by_user_id == user.id) + .where(L1WalkSession.status == 'active') + .order_by(L1WalkSession.last_step_at.desc()) + .limit(20) + ) + result = await db.execute(stmt) + return [_as_response(s) for s in result.scalars()] + + +@router.post("/sessions/{session_id}/step", response_model=WalkSessionResponse) +async def post_step( + session_id: UUID, + payload: StepRequest, + user: User = Depends(require_l1_or_coverage), + db: AsyncSession = Depends(get_db), +): + session = await db.get(L1WalkSession, session_id) + if not session or session.account_id != user.account_id: + raise HTTPException(status_code=404) + try: + updated = await l1_session_service.record_step( + db, session_id=session_id, + node_id=payload.node_id, question=payload.question, + answer=payload.answer, note=payload.note, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + await db.commit() + return _as_response(updated) + + +@router.post("/sessions/{session_id}/notes", response_model=WalkSessionResponse) +async def post_notes( + session_id: UUID, + payload: NotesRequest, + user: User = Depends(require_l1_or_coverage), + db: AsyncSession = Depends(get_db), +): + session = await db.get(L1WalkSession, session_id) + if not session or session.account_id != user.account_id: + raise HTTPException(status_code=404) + try: + updated = await l1_session_service.update_notes( + db, session_id=session_id, notes=payload.notes, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + await db.commit() + return _as_response(updated) + + +@router.post("/sessions/{session_id}/resolve", response_model=WalkSessionResponse) +async def post_resolve( + session_id: UUID, + payload: ResolveRequest, + user: User = Depends(require_l1_or_coverage), + db: AsyncSession = Depends(get_db), +): + session = await db.get(L1WalkSession, session_id) + if not session or session.account_id != user.account_id: + raise HTTPException(status_code=404) + try: + updated = await l1_session_service.resolve( + db, session_id=session_id, helpful=payload.helpful, + resolution_notes=payload.resolution_notes, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + await db.commit() + return _as_response(updated) + + +@router.post("/sessions/{session_id}/escalate", response_model=WalkSessionResponse) +async def post_escalate( + session_id: UUID, + payload: EscalateRequest, + user: User = Depends(require_l1_or_coverage), + db: AsyncSession = Depends(get_db), +): + session = await db.get(L1WalkSession, session_id) + if not session or session.account_id != user.account_id: + raise HTTPException(status_code=404) + try: + updated = await l1_session_service.escalate( + db, session_id=session_id, + reason=payload.reason or '', + reason_category=payload.reason_category, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + await db.commit() + return _as_response(updated) + + +@router.post("/escalate-without-walk", response_model=WalkSessionResponse) +async def post_escalate_without_walk( + payload: EscalateWithoutWalkRequest, + user: User = Depends(require_l1_or_coverage), + db: AsyncSession = Depends(get_db), +): + ticket = await internal_ticket_service.create_ticket( + db, + account_id=user.account_id, + created_by_user_id=user.id, + problem_statement=payload.problem_statement, + customer_name=payload.customer_name, + customer_contact=payload.customer_contact, + ) + session = await l1_session_service.escalate_without_walk( + db, + account_id=user.account_id, + user=user, + ticket_id=str(ticket.id), + ticket_kind='internal', + reason_category=payload.reason_category, + reason=payload.reason, + ) + await db.commit() + return _as_response(session) +``` + +- [ ] **Step 3: Register the router.** + +In `backend/app/api/router.py`, add: + +```python +from app.api.endpoints import l1 +api_router.include_router(l1.router) +``` + +- [ ] **Step 4: Add E2E endpoint tests.** + +In `backend/tests/test_l1_endpoints.py`, add: + +```python +@pytest.mark.asyncio +async def test_intake_creates_adhoc_session_when_no_flow_id(authed_l1_client): + response = await authed_l1_client.post( + "/api/v1/l1/intake", + json={"problem_statement": "outlook broken", "customer_name": "Alice"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["session_kind"] == "adhoc" + assert body["ticket_kind"] == "internal" + + +@pytest.mark.asyncio +async def test_intake_creates_flow_session_when_flow_id_provided(authed_l1_client, flow): + response = await authed_l1_client.post( + "/api/v1/l1/intake", + json={"problem_statement": "outlook broken", "flow_id": str(flow.id)}, + ) + assert response.status_code == 200 + assert response.json()["session_kind"] == "flow" + + +@pytest.mark.asyncio +async def test_step_appends_to_walked_path(authed_l1_client, active_flow_session): + response = await authed_l1_client.post( + f"/api/v1/l1/sessions/{active_flow_session.id}/step", + json={"node_id": "n1", "question": "q1", "answer": "yes", "note": None}, + ) + assert response.status_code == 200 + assert len(response.json()["walked_path"]) == 1 + + +@pytest.mark.asyncio +async def test_step_blocked_on_adhoc_session(authed_l1_client, active_adhoc_session): + response = await authed_l1_client.post( + f"/api/v1/l1/sessions/{active_adhoc_session.id}/step", + json={"node_id": "n1", "question": "q1", "answer": "yes"}, + ) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_escalate_without_walk_creates_escalated_session(authed_l1_client): + response = await authed_l1_client.post( + "/api/v1/l1/escalate-without-walk", + json={ + "problem_statement": "no kb yet", + "reason_category": "No KB available", + }, + ) + assert response.status_code == 200 + assert response.json()["status"] == "escalated" + + +@pytest.mark.asyncio +async def test_l1_endpoints_block_viewer(authed_viewer_client): + response = await authed_viewer_client.post( + "/api/v1/l1/intake", + json={"problem_statement": "x"}, + ) + assert response.status_code == 403 +``` + +- [ ] **Step 5: Run all L1 endpoint tests.** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_l1_endpoints.py -v +``` + +- [ ] **Step 6: Commit.** + +```bash +git add backend/app/schemas/l1.py backend/app/api/endpoints/l1.py backend/app/api/router.py backend/tests/test_l1_endpoints.py +git commit -m "feat(l1): L1 endpoints (intake/queue/sessions/step/notes/resolve/escalate)" +``` + +--- + +## Task 16: APScheduler cleanup job for abandoned sessions + +**Files:** +- Create: `backend/app/services/l1_session_cleanup.py` +- Modify: `backend/app/main.py` — register the job in lifespan +- Test: `backend/tests/test_l1_session_cleanup.py` + +- [ ] **Step 1: Write failing test.** + +```python +import pytest +from datetime import datetime, timedelta, timezone +from app.services.l1_session_cleanup import flip_stale_sessions + + +@pytest.mark.asyncio +async def test_flip_stale_sessions_abandons_old_active_sessions(db_session, account, l1_user): + # Insert one stale active + one fresh active + one already resolved + stale = L1WalkSession( + account_id=account.id, + created_by_user_id=l1_user.id, + ticket_id='x', ticket_kind='internal', session_kind='adhoc', + status='active', + last_step_at=datetime.now(timezone.utc) - timedelta(hours=25), + walked_path=[], walk_notes=[], + ) + fresh = L1WalkSession( + account_id=account.id, + created_by_user_id=l1_user.id, + ticket_id='y', ticket_kind='internal', session_kind='adhoc', + status='active', + last_step_at=datetime.now(timezone.utc) - timedelta(hours=1), + walked_path=[], walk_notes=[], + ) + db_session.add_all([stale, fresh]) + await db_session.flush() + count = await flip_stale_sessions(db_session) + assert count == 1 + await db_session.refresh(stale) + await db_session.refresh(fresh) + assert stale.status == 'abandoned' + assert fresh.status == 'active' +``` + +- [ ] **Step 2: Run, verify failure.** + +- [ ] **Step 3: Implement.** + +`backend/app/services/l1_session_cleanup.py`: + +```python +import logging +from datetime import datetime, timedelta, timezone + +from sqlalchemy import update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.l1_walk_session import L1WalkSession + + +logger = logging.getLogger(__name__) + + +async def flip_stale_sessions(db: AsyncSession) -> int: + """ + Flip active sessions to 'abandoned' if their last_step_at is older than 24 hours. + Returns the number of rows flipped. + """ + cutoff = datetime.now(timezone.utc) - timedelta(hours=24) + stmt = ( + update(L1WalkSession) + .where(L1WalkSession.status == 'active') + .where(L1WalkSession.last_step_at < cutoff) + .values(status='abandoned') + ) + result = await db.execute(stmt) + await db.commit() + return result.rowcount or 0 + + +async def run_cleanup_job(session_factory) -> None: + """Entrypoint for APScheduler — uses _admin_session_factory per Lesson on RLS at startup.""" + async with session_factory() as db: + try: + count = await flip_stale_sessions(db) + if count > 0: + logger.info("l1_session_cleanup: flipped %d sessions to abandoned", count) + except Exception: + logger.exception("l1_session_cleanup: error during run") +``` + +- [ ] **Step 4: Register the job in `main.py` lifespan.** + +In `backend/app/main.py`, find the lifespan / APScheduler setup and add: + +```python +from app.services.l1_session_cleanup import run_cleanup_job + +# Inside the lifespan startup section, alongside other scheduled jobs: +scheduler.add_job( + run_cleanup_job, + 'interval', + hours=1, + max_instances=1, # Lesson 1 + args=[_admin_session_factory], + id='l1_session_cleanup', + replace_existing=True, +) +``` + +- [ ] **Step 5: Run unit test, verify pass.** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_l1_session_cleanup.py -v +``` + +- [ ] **Step 6: Verify job registered at startup (manual smoke).** + +Restart backend, grep logs for `l1_session_cleanup` job-registered line. APScheduler logs job registration at startup. + +- [ ] **Step 7: Commit.** + +```bash +git add backend/app/services/l1_session_cleanup.py backend/app/main.py backend/tests/test_l1_session_cleanup.py +git commit -m "feat(l1): APScheduler hourly cleanup job for abandoned sessions" +``` + +--- + +## Task 17: RLS regression tests for new tables + +**Files:** +- Create: `backend/tests/test_l1_rls.py` + +- [ ] **Step 1: Write the tests.** + +```python +import pytest +from sqlalchemy import select +from app.models.internal_ticket import InternalTicket +from app.models.l1_walk_session import L1WalkSession + + +@pytest.mark.asyncio +async def test_l1_cannot_read_other_accounts_internal_tickets( + db_account_a_session, account_b_internal_ticket +): + """RLS must block cross-tenant reads on internal_tickets.""" + stmt = select(InternalTicket).where(InternalTicket.id == account_b_internal_ticket.id) + result = await db_account_a_session.execute(stmt) + assert result.scalar_one_or_none() is None + + +@pytest.mark.asyncio +async def test_l1_cannot_read_other_accounts_walk_sessions( + db_account_a_session, account_b_walk_session +): + stmt = select(L1WalkSession).where(L1WalkSession.id == account_b_walk_session.id) + result = await db_account_a_session.execute(stmt) + assert result.scalar_one_or_none() is None + + +@pytest.mark.asyncio +async def test_with_check_blocks_cross_tenant_insert(db_account_a_session, account_b): + """RLS WITH CHECK must reject inserts setting another account's account_id.""" + bad_row = InternalTicket( + account_id=account_b.id, + created_by_user_id=..., + problem_statement='cross-tenant attempt', + ) + db_account_a_session.add(bad_row) + with pytest.raises(Exception): # InsufficientPrivilegeError or similar + await db_account_a_session.flush() +``` + +(Use fixtures from the existing RLS test suite — `db_account_a_session` already exists per the project's tenant-isolation Phase 4 work. Adapt to actual fixture names.) + +- [ ] **Step 2: Run.** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_l1_rls.py -v +``` +Expected: 3 PASSED. + +- [ ] **Step 3: Commit.** + +```bash +git add backend/tests/test_l1_rls.py +git commit -m "test(l1): RLS regression tests for internal_tickets + l1_walk_sessions" +``` + +--- + +## Task 18: Frontend — `usePermissions` extensions + role type + +**Files:** +- Modify: `frontend/src/types/auth.ts` (or `frontend/src/types/user.ts` — wherever `User.account_role` is typed) +- Modify: `frontend/src/hooks/usePermissions.ts` + +- [ ] **Step 1: Locate the role union type.** + +```bash +grep -rn "account_role:" frontend/src/types/ +``` + +- [ ] **Step 2: Add `'l1_tech'` to the union.** + +In the relevant file (likely `frontend/src/types/auth.ts`), find the type: + +```typescript +export type AccountRole = 'owner' | 'engineer' | 'viewer' +``` + +Change to: + +```typescript +export type AccountRole = 'owner' | 'engineer' | 'l1_tech' | 'viewer' +``` + +- [ ] **Step 3: Update `usePermissions.ts`.** + +Open [usePermissions.ts](../../frontend/src/hooks/usePermissions.ts). Find the `getEffectiveRole` and `hasMinimumRole` functions and update: + +```typescript +type EffectiveRole = 'super_admin' | 'owner' | 'engineer' | 'l1_tech' | 'viewer' + +function getEffectiveRole(user: User | null): EffectiveRole { + if (!user) return 'viewer' + if (user.is_super_admin) return 'super_admin' + if (user.account_role === 'owner') return 'owner' + if (user.account_role === 'engineer') return 'engineer' + if (user.account_role === 'l1_tech') return 'l1_tech' + return 'viewer' +} + +const ROLE_RANK: Record = { + super_admin: 5, + owner: 4, + engineer: 3, + l1_tech: 2, + viewer: 1, +} + +function hasMinimumRole(user: User | null, minimum: EffectiveRole): boolean { + const effective = getEffectiveRole(user) + return ROLE_RANK[effective] >= ROLE_RANK[minimum] +} +``` + +And in the main return object of `usePermissions()`, add: + +```typescript +return { + // ... existing returned values ... + isL1Tech: effectiveRole === 'l1_tech', + canCoverL1: Boolean(user?.can_cover_l1) || effectiveRole === 'owner' || effectiveRole === 'super_admin', + canUseL1Surface: effectiveRole === 'l1_tech' || effectiveRole === 'owner' || effectiveRole === 'super_admin' || (user?.account_role === 'engineer' && Boolean(user?.can_cover_l1)), + canUseEngineerSurface: hasMinimumRole(user, 'engineer'), +} +``` + +- [ ] **Step 4: Update User type to include `can_cover_l1`.** + +In the User type definition: + +```typescript +export interface User { + id: string + email: string + // ... existing fields ... + account_role: AccountRole + is_super_admin: boolean + can_cover_l1: boolean // NEW + // ... rest ... +} +``` + +- [ ] **Step 5: Run frontend type check.** + +```bash +docker exec -w /app resolutionflow_frontend npx tsc -b +``` +Expected: no new type errors (existing usages of `account_role` may need updates — fix any compilation errors caused by the union widening). + +- [ ] **Step 6: Commit.** + +```bash +git add frontend/src/types/auth.ts frontend/src/hooks/usePermissions.ts +git commit -m "feat(l1): usePermissions extensions for l1_tech + coverage flag" +``` + +--- + +## Task 19: Frontend — Sidebar role-based nav + ProtectedRoute redirect + +**Files:** +- Modify: `frontend/src/components/layout/Sidebar.tsx` +- Modify: `frontend/src/components/layout/ProtectedRoute.tsx` + +- [ ] **Step 1: Update `Sidebar.tsx` to render role-based nav.** + +Find the nav array construction in [Sidebar.tsx](../../frontend/src/components/layout/Sidebar.tsx). Wrap it in a helper that picks per role: + +```typescript +import { usePermissions } from '@/hooks/usePermissions' +import { LayoutGrid, Ticket, FileText, BookOpen, Settings } from 'lucide-react' + +function getNavItems(perms: ReturnType) { + // L1 tech sees only L1-relevant entries + if (perms.isL1Tech) { + return [ + { href: '/l1', icon: LayoutGrid, label: 'Workspace', shortLabel: 'Work' }, + { href: '/l1/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets' }, + { href: '/l1/drafts', icon: FileText, label: 'My Drafts', shortLabel: 'Drafts' }, + { href: '/guides', icon: BookOpen, label: 'Guides', shortLabel: 'Guides' }, + { href: '/account', icon: Settings, label: 'Account', shortLabel: 'Acct' }, + ] + } + + // Existing engineer/owner nav (kept as-is) + const items = [/* ... existing items ... */] + + // Append L1 Workspace entry for coverage engineers + owners + if (perms.canCoverL1) { + items.push({ + href: '/l1', icon: LayoutGrid, label: 'L1 Workspace', shortLabel: 'L1', + }) + } + return items +} + +// Inside the Sidebar component: +const perms = usePermissions() +const navItems = getNavItems(perms) +``` + +- [ ] **Step 2: Add L1 post-login redirect to `ProtectedRoute.tsx`.** + +In [ProtectedRoute.tsx](../../frontend/src/components/layout/ProtectedRoute.tsx), find the existing auth-state handling and add: + +```typescript +// After authentication checks pass, before rendering children: +if (user?.account_role === 'l1_tech' && location.pathname === '/') { + return +} +``` + +- [ ] **Step 3: Type-check.** + +```bash +docker exec -w /app resolutionflow_frontend npx tsc -b +``` + +- [ ] **Step 4: Commit.** + +```bash +git add frontend/src/components/layout/Sidebar.tsx frontend/src/components/layout/ProtectedRoute.tsx +git commit -m "feat(l1): role-based sidebar nav + L1 post-login redirect" +``` + +--- + +## Task 20: Frontend — router updates + `L1RouteGuard` + +**Files:** +- Create: `frontend/src/components/layout/L1RouteGuard.tsx` +- Modify: `frontend/src/router.tsx` + +- [ ] **Step 1: Create the guard component.** + +```tsx +import { Navigate } from 'react-router' +import { usePermissions } from '@/hooks/usePermissions' + +export function L1RouteGuard({ children }: { children: React.ReactNode }) { + const perms = usePermissions() + if (!perms.canUseL1Surface) { + return + } + return <>{children} +} +``` + +- [ ] **Step 2: Register routes.** + +In `frontend/src/router.tsx`, near the other `lazyWithRetry` declarations: + +```typescript +const L1Dashboard = lazyWithRetry(() => import('@/pages/l1/L1Dashboard')) +const L1WalkPage = lazyWithRetry(() => import('@/pages/l1/L1WalkPage')) +const L1DraftsPage = lazyWithRetry(() => import('@/pages/l1/L1DraftsPage')) +const L1TicketsPage = lazyWithRetry(() => import('@/pages/l1/L1TicketsPage')) +``` + +And inside the `/` ProtectedRoute children array, alongside existing routes: + +```typescript +{ path: 'l1', element: }, +{ path: 'l1/walk/:sessionId', element: }, +{ path: 'l1/drafts', element: }, +{ path: 'l1/tickets', element: }, +``` + +The `lazyWithRetry`-wrapped components are React.lazy under the hood, so they need a Suspense boundary already provided by the existing `page()` helper. Adapt to whichever pattern the existing routes use (likely `element: page(L1Dashboard)` wrapped manually, or pass through the guard). + +Adjust based on actual router pattern: + +```typescript +{ path: 'l1', element: {page(L1Dashboard)} }, +``` + +- [ ] **Step 3: Type-check.** + +```bash +docker exec -w /app resolutionflow_frontend npx tsc -b +``` +(Will fail until the actual page components exist in Task 21+. That's OK — placeholder check that imports resolve.) + +- [ ] **Step 4: Commit (pages stubbed in next task).** + +```bash +git add frontend/src/components/layout/L1RouteGuard.tsx frontend/src/router.tsx +git commit -m "feat(l1): register /l1/* routes + L1RouteGuard" +``` + +--- + +## Task 21: Frontend — `L1Dashboard` (active + empty state) + +**Files:** +- Create: `frontend/src/pages/l1/L1Dashboard.tsx` +- Create: `frontend/src/components/l1/EmptyStateCard.tsx` +- Create: `frontend/src/components/l1/ResumeInProgress.tsx` +- Create: `frontend/src/api/l1.ts` +- Create: `frontend/src/types/l1.ts` + +- [ ] **Step 1: Create types.** + +`frontend/src/types/l1.ts`: + +```typescript +export type SessionKind = 'flow' | 'proposal' | 'adhoc' +export type SessionStatus = 'active' | 'resolved' | 'escalated' | 'abandoned' +export type TicketKind = 'psa' | 'internal' + +export interface WalkSession { + id: string + session_kind: SessionKind + flow_id: string | null + flow_proposal_id: string | null + current_node_id: string | null + walked_path: WalkStep[] + walk_notes: AdhocNote[] + status: SessionStatus + started_at: string + last_step_at: string + resolved_at: string | null +} + +export interface WalkStep { + node_id: string + question: string + answer: string + l1_note: string | null +} + +export interface AdhocNote { + timestamp: string + content: string +} + +export interface QueueRow { + ticket_id: string + ticket_kind: TicketKind + problem_statement: string | null + customer_name: string | null + status: string + created_at: string | null +} + +export interface IntakeRequest { + problem_statement: string + customer_name?: string + customer_contact?: string + flow_id?: string +} + +export interface IntakeResponse { + session_id: string + session_kind: SessionKind + ticket_id: string + ticket_kind: TicketKind +} +``` + +- [ ] **Step 2: Create API client.** + +`frontend/src/api/l1.ts`: + +```typescript +import { apiClient } from './client' +import type { + IntakeRequest, IntakeResponse, QueueRow, WalkSession, + WalkStep, AdhocNote, +} from '@/types/l1' + +export const l1Api = { + intake: (body: IntakeRequest) => + apiClient.post('/api/v1/l1/intake', body).then(r => r.data), + + queue: (statusFilter?: string) => + apiClient.get('/api/v1/l1/queue', { + params: statusFilter ? { status_filter: statusFilter } : {}, + }).then(r => r.data), + + getSession: (sessionId: string) => + apiClient.get(`/api/v1/l1/sessions/${sessionId}`).then(r => r.data), + + listActiveSessions: () => + apiClient.get('/api/v1/l1/sessions/active').then(r => r.data), + + step: (sessionId: string, step: { node_id: string; question: string; answer: string; note?: string | null }) => + apiClient.post(`/api/v1/l1/sessions/${sessionId}/step`, step).then(r => r.data), + + notes: (sessionId: string, notes: AdhocNote[]) => + apiClient.post(`/api/v1/l1/sessions/${sessionId}/notes`, { notes }).then(r => r.data), + + resolve: (sessionId: string, body: { helpful: boolean; resolution_notes: string }) => + apiClient.post(`/api/v1/l1/sessions/${sessionId}/resolve`, body).then(r => r.data), + + escalate: (sessionId: string, body: { reason: string; reason_category: string }) => + apiClient.post(`/api/v1/l1/sessions/${sessionId}/escalate`, body).then(r => r.data), + + escalateWithoutWalk: (body: { + problem_statement: string; customer_name?: string; customer_contact?: string; + reason_category: string; reason?: string; + }) => + apiClient.post('/api/v1/l1/escalate-without-walk', body).then(r => r.data), +} +``` + +- [ ] **Step 3: Create EmptyStateCard.** + +`frontend/src/components/l1/EmptyStateCard.tsx`: + +```tsx +import { usePermissions } from '@/hooks/usePermissions' + +interface Props { + onUploadClick?: () => void + onConfigureConnectorClick?: () => void +} + +export function EmptyStateCard({ onUploadClick, onConfigureConnectorClick }: Props) { + const perms = usePermissions() + const isOwnerOrCoverage = perms.canCoverL1 // (owner/super_admin always have it) + + return ( +
+

+ Your knowledge base is empty +

+

+ L1 Workspace works best when your account has KB content or authored flows. + Right now there's nothing to match against — calls will start as ad-hoc walks. +

+ {isOwnerOrCoverage ? ( +
+ {onUploadClick && ( + + )} + {onConfigureConnectorClick && ( + + )} +
+ ) : ( +
    +
  • Ask your admin to upload KB documents
  • +
  • Or configure a KB connector under Account → KB
  • +
  • Or author a flow in the Flows library
  • +
+ )} +
+ ) +} +``` + +- [ ] **Step 4: Create ResumeInProgress.** + +`frontend/src/components/l1/ResumeInProgress.tsx`: + +```tsx +import { useEffect, useState } from 'react' +import { Link } from 'react-router' +import { l1Api } from '@/api/l1' +import type { WalkSession } from '@/types/l1' + +export function ResumeInProgress() { + const [sessions, setSessions] = useState(null) + + useEffect(() => { + l1Api.listActiveSessions().then(setSessions).catch(() => setSessions([])) + }, []) + + if (!sessions || sessions.length === 0) return null + + return ( +
+
+ + Resume in progress · {sessions.length} + +
+
+
+ {sessions.map((s) => ( + +
+ #{s.id.slice(0, 8)} + + {s.session_kind === 'adhoc' + ? `Adhoc · ${s.walk_notes.length} notes` + : `Step ${s.walked_path.length}`} + +
+ + {new Date(s.last_step_at).toLocaleTimeString()} + + + ))} +
+
+ ) +} +``` + +- [ ] **Step 5: Create the Dashboard page.** + +`frontend/src/pages/l1/L1Dashboard.tsx`: + +```tsx +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' +import { PageMeta } from '@/components/common/PageMeta' +import { useAuthStore } from '@/store/authStore' +import { l1Api } from '@/api/l1' +import { EmptyStateCard } from '@/components/l1/EmptyStateCard' +import { ResumeInProgress } from '@/components/l1/ResumeInProgress' +import type { QueueRow } from '@/types/l1' +// Existing helper to fetch flows + KB doc count: +import { dashboardApi } from '@/api/dashboard' // adjust import to actual path + +export default function L1Dashboard() { + const user = useAuthStore((s) => s.user) + const navigate = useNavigate() + const [problem, setProblem] = useState('') + const [customerName, setCustomerName] = useState('') + const [customerContact, setCustomerContact] = useState('') + const [submitting, setSubmitting] = useState(false) + const [queue, setQueue] = useState([]) + const [isEmpty, setIsEmpty] = useState(null) + + useEffect(() => { + l1Api.queue('open').then(setQueue).catch(() => setQueue([])) + // Detect empty state via the dashboard stats endpoint (existing). + // Adjust to actual endpoint that returns flow + KB counts. + dashboardApi.getStats().then(stats => { + setIsEmpty((stats?.flow_count ?? 0) === 0 && (stats?.kb_doc_count ?? 0) === 0) + }).catch(() => setIsEmpty(false)) + }, []) + + const handleStart = async () => { + if (!problem.trim()) return + setSubmitting(true) + try { + const response = await l1Api.intake({ + problem_statement: problem.trim(), + customer_name: customerName.trim() || undefined, + customer_contact: customerContact.trim() || undefined, + }) + navigate(`/l1/walk/${response.session_id}`) + } finally { + setSubmitting(false) + } + } + + const now = new Date() + const greeting = now.getHours() < 12 ? 'morning' : now.getHours() < 18 ? 'afternoon' : 'evening' + const firstName = user?.name?.split(' ')[0] || 'there' + + return ( +
+ +
+ {/* Hero greeting */} +
+

+ {now.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })} +

+

+ Good {greeting}, {firstName}. +

+
+ + {/* Empty state (first-run) */} + {isEmpty && } + + {/* Describe the problem */} +
+
+ + + Describe the problem + +
+
+