Winston — pull requests

betterup/legal-dashboard · every PR, newest first · as of 2026-09-15

105total
88merged
7open
10closed, not merged
39Nick Raffey
61Joe Chiusano
5Wiz (bot)
#106
docs: routing matrix — product legal → Elisa Cooke (Karen Lawson left)
Nick RaffeyLegal Front Dooropened 2026-09-15merged 2026-09-151 file, +1 / −1
Merged
Karen Lawson left BetterUp 2026-09-15. Product legal / coaching data / member data questions now route to Elisa Cooke (Nick). Docs only.
#105
lfd: the triager bell waits for Salesforce, and the pollers can no longer die quietly
Nick RaffeyLegal Front Dooropened 2026-09-15merged 2026-09-157 files, +650 / −47
Merged
Two silent failures from the 2026-09-14 audit, both in functions/ only. Nothing deployed. Fix 1 — the triager bell rings for auto-assigned tickets The 09-14 guard (const arrivesOwned = !!after.ownerSlackId on the create) could never fire. The real write sequence for a Slack-form filing is three writes: 1. recordIntake — create, ownerSlackId: null unconditionally (intake-record.ts) 2. stampSfOwnerAtFiling — merges sfOwnerId (lfd-intake.ts) 3. lfdSfMirror / handleSfMirrorChange — maps sfOwnerId → ownerSlackId (lfd-triage.ts) The create snapshot the bell reads simply does not know the owner yet, so every auto-assigned ticket rang every triager and then got its owner — four of Nick's five stuck bells on 09-14. Shape chosen: (a), the sfOwnerPending flag. (b) — "ring on the owner-stamp merge instead" — collapses into the same thing on inspection: to know that a stamp is coming you still need a marker on the create, otherwise a create with no stamp behind it (questions, web filings, the self-serve lanes) never rings anyone at all. Given that, (a) is the shape that matches how this file already solved the identical problem for selfServe in September: carry the fact on the CREATE, because a marker merged afterwards arrives one write too late for a trigger that reads the create snapshot. It also keeps the decision in one place (lfd-notify.ts) instead of splitting it across the stamp's callers. How it behaves: • recordIntake writes sfOwnerPending explicitly (always, so an old-doc gap and a real false can't be confused). Only the Slack-form filing path sets it, as !selfServe — exactly mirroring the if (requestId && !selfServe) stampSfOwnerAtFiling(...) call two statements later. • Triagers ring on a create with no flag (questions, web filings, ironclad-nda / sf-beta-terms …
#104
chore: ignore .env.bak* copies; matrix note — Order Form routes to Tara
Nick RaffeyPlatformopened 2026-09-15merged 2026-09-152 files, +5 / −2
Merged
Gitignore the pre-deploy .env.bak-* copies under functions (untracked secrets). Fix the stale routing-matrix note: Order Form has a live prod rule, canary Case 00025024 landed with Tara Johnson.
#103
docs: routing matrix — MSA routes to Sasha; ESA rows match the prod rules
Nick RaffeyPlatformopened 2026-09-15merged 2026-09-151 file, +18 / −1
Merged
Nick (2026-09-15): keep MSA as a request type, route it to Sasha Nichols. Adds the MSA matrix row (drives Winston's question auto-assign) and records Joe's 09-10 MSA-review assignment rule as the binding Salesforce entry — Joe to confirm it is active in prod. Also lands the 09-14 ESA-row correction that was uncommitted. Docs only; nothing to deploy.
#102
lfd: no MSA request type — a customer's master agreement is an ESA on their paper
Nick RaffeyLegal Front Dooropened 2026-09-15closed 2026-09-156 files, +116 / −54
Closed
Why Nick, 2026-09-15: MSA should not be an option in the dropdown, it should be ESA. The 09-11 "MSA" row is retired on both surfaces; a customer's master agreement sent for review is the ESA conversation on their paper, and still routes on the ESA type. What changes Slack form (functions/src/lfd-intake.ts) • REQUEST_TYPES loses the msa row — the modal select, Claude extract enum and card label lookup derive from it. • msa::MSA, msa::MSA Review, msa::Other become legacy tokens resolving to esa::ESA (stale modals and old cards keep filing). • formShape pins a legacy msa:: token to counterparty paper by raw token; a live ESA pick keeps the requester's answer. • Free-text "msa" / "master agreement" now suggests ESA; extract prompt updated. • THEIR_PAPER_GUIDANCE.esa added (the old MSA checklist, where it now lives). Page-1 hint says "pick ESA + Counterparty's paper". Web chat (src/components/Chat2) • MSA chip removed. Upload path's "Customer paper (MSA) review" is now the ESA type relabelled, carrying the four ESA variants so the follow-up still picks the routing value. chooseDocType delegates to chooseType when the entry has variants. Docs: routing matrix drops MSA (Commercial row + open-items note). Also carries the 09-14 ESA-row correction that was uncommitted.
#101
lfd: the beta gate guards the door, not the right to answer
Nick RaffeyLegal Front Dooropened 2026-09-15merged 2026-09-153 files, +394 / −2
Merged
A lawyer on config/legalTriage.roster but not on LFD_INTAKE_ALLOWED_USERS could not answer anything through Winston. Her reply was discarded and she got the private-beta notice instead. Found live today: Narmada was added to the roster, the routing matrix assigned her a real question, Winston DMed her, she replied in that thread — [lfd-intake] non-allowlisted DM from U09HL3N51E2 — beta notice sent. The requester heard nothing and the ticket logged nothing (messages: 0). The gate at lfd-intake.ts answered two different questions with one list: • may this person open a request? • may this person speak on a ticket that already exists? It sits in front of the reply relay, so the env var silently governed both. Sasha worked only because someone added her by hand. Every future roster addition had the same trap, and AEs would have hit it too.
#100
lfd: grant the legal roster Legal Front Door access (script + runbook)
Joe ChiusanoLegal Front Dooropened 2026-09-15merged 2026-09-153 files, +652 / −0
Merged
Go-live item 4, "give the lawyers access to the Legal Front Door module". Access is per user — users/{uid} needs role legal (or admin) and legalFrontDoor in modules. The legal role's default package is the base modules only (ROLE_DEFAULT_MODULES), and an invite carries a role but no modules, so each lawyer otherwise needs an admin to open Operator → Users after their first sign-in and tick the module. functions/scripts/grant-lfd-access.mjs does everyone on the config/legalTriage roster in one pass, before or after sign-in. Nothing to deploy. What it does bash node scripts/grant-lfd-access.mjs --project legal-dashboard-ee977 # dry run node scripts/grant-lfd-access.mjs --project legal-dashboard-ee977 --apply • Found — Action • no Firebase Auth user — create it (same mechanism provision-users.mjs used for the SVPs — Google sign-in links to the same uid) and create users/{uid} with role legal, modules [legalFrontDoor] • auth user, no doc — create the doc • role admin, or the pinned operator — role untouched; module stored if missing — firestore.rules hasModule() only bypasses the email-pinned operator, so a role-admin needs the stored module • role legal — modules += legalFrontDoor (no-op if present) • any other role, or none — skipped unless --promote-non-legal (that move widens them to every deal and legal internals); with it, role → legal, module added, a former sales user keeps myDeals • Every users write is a per-user transaction that re-reads and re-plans right before writing (create for missing docs), so a first sign-in or an admin edit in between is never overwritten. • Preflight: reads the project's sign-in config and refuses the whole run if allowDuplicateEmails is on or unreadable — pre-created accounts only link to Google sign-in, and email look…
#99
lfd: lawyer notifications are a console switch (config/legalTriage.lawyerNotifications)
Joe ChiusanoLegal Front Dooropened 2026-09-15merged 2026-09-157 files, +589 / −56
Merged
Go-live item 3, "turn on notifications for lawyers", had nothing to turn: the only control was the compile-time constant MUTE_LAWYER_DMS_FOR_TEST_TRAFFIC in lfd-triage.ts (already false on main, and it only ever muted Nick's and Joe's own test tickets), and the in-app bell had no switch at all — sfMirrorSilent quiets the mirror's Slack side but every backfilled ticket still rang every triager. The switch config/legalTriage.lawyerNotifications, read per event (not through the 60-second config memo), no deploy to flip: json { "slack": true, "inApp": true, "muteRequesterSlackIds": ["U0A67S14DA8", "U0AUL4R57QQ"] } • Key — Off means — Untouched either way • slack: false — no assignee DM, no handoff transcript, no nudge DM to the lawyer — triage card, requester's own thread and receipt, digest, Salesforce • inApp: false — no bell for new tickets or assignments — everything else • muteRequesterSlackIds — tickets filed by these users wake no lawyer on either channel (the old hard-coded Nick+Joe mute, now config) — the ticket, its card, the Case • Absent field, absent key, or anything but a literal false is on. A typo can only silence nothing. • Held back, not queued. A lawyer assigned while a channel is off is not told later. That is what off has to mean during a backfill. • One log line per suppression, so "why didn't Flor get the DM" is answerable from the function log. • A reassignment while Slack is off still moves the ticket cleanly: the previous lawyer's DM thread stops being the reply-relay anchor (thread index deleted, pointer cleared), the courtesy note to them is held, and requester replies fall back to the triage-card thread, labelled "with X, no DM thread to relay into" rather than "unassigned". The same cleanup now also runs when Slack refuses the new DM (bef…
#98
Add guarded Winston sandbox-ticket purge tool
Joe ChiusanoLegal Front Dooropened 2026-09-15closed 2026-09-153 files, +735 / −0
Closed
Legal Front Door's pre-pilot tickets were filed against BU_Full, but there was no production-safe way to clear their Winston queue entries and triage cards before the pilot. This adds a manifest-driven purge tool, an offline harness, and Nick's runbook. The branch starts from main at 02c74fd642ebfade9beabdfa2703803960a584de. Only these files change: • functions/scripts/purge-sandbox-tickets.mjs • functions/scripts/purge-sandbox-tickets-harness.cjs • docs/lfd-purge-runbook.md Behavior and enforced boundaries Phase 1, --confirm-target production --manifest, writes purge-manifest.json and exits without deleting. It lists ids and review metadata for eligible legalIntakeRequests, their lfdThreadIndex rows, and notifications. Requests with sandbox true or absent qualify; pre-pilot question tickets need no Case. Explicit sandbox false always wins. The current notification writer uses targetType: "lfdRequest" plus targetId, so the tool supports that shape as well as requestId, without touching unrelated notifications. Phase 2 defaults to dry-run; deletion requires --apply --manifest PATH and the production confirmation flag. A conflicting environment LFD_TARGET_ORG, emulator, wrong project, malformed manifest, or unsafe flags is refused. No functions/.env is loaded. The Admin SDK uses applicationDefault() and the pinned project legal-dashboard-ee977. Apply re-reads only manifested ids, skips production/changed docs and protected parents, then exclusively writes/fsyncs a full typed JSON export before any deletion. Firestore transactions recheck parents and each document's clocks, content fingerprint and update time; commits contain at most 400 deletes, with dependents first and parent last. Repeating a successful apply is a no-op with exit 0. Only recorded triage cards …
#97
lfd: a Government ESA is never generated on the commercial paper
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-154 files, +73 / −11
Merged
Audit P0 (2026-09-14): since #88 asks the ESA type, but the generator still launched every ESA as the commercial "Customer ESA" and told the AE to send it. A government customer could receive the wrong contract. • docGenerateBlocked returns gov-variant for ESA – Government before any other check. The Case still files and routes exactly as before; legal drafts on the Gov paper. Requester, triage, Case description and launch-error copy all say so. • An ESA with no known type (a page 2 opened before #88, a legacy plain ESA token) fails closed with no-esa-type instead of generating. • The type is passed to launchDocumentWorkflow, whose own gate enforces the same rule for any later caller. • lfdDocReadyPoll quarantines a Government ESA that was launched before this shipped: it is retired (docFailedReason: gov-variant), the requester is told legal drafts it, and the commercial draft is never posted. • Non-Government, Financial Institutions and New Business still generate. Whether Financial Institutions should also be drafted by legal is a legal call; nothing in the repo says it needs different paper.
#96
lfd: production cutover switch (LFD_TARGET_ORG) with an exact-org login gate
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-1519 files, +746 / −107
Merged
Lets Winston file Salesforce Cases into production with one setting, without touching the deal calculator. Before this, the Legal Front Door could only reach the Full sandbox: • sf-demo.ts login refused any org that wasn't a sandbox. • Every ticket was stamped sandbox: true. • One module-wide Salesforce session was shared by the Legal Front Door and the deal calculator. A warm instance would reuse whichever org logged in first. The switch: LFD_TARGET_ORG • Value — Legal Front Door reaches • unset or sandbox — The Full sandbox, exactly as today • production — BetterUp production only. Login checks IsSandbox = false and org id 00D50000000bfiIEAQ, or refuses • anything else (e.g. off) — Nothing: no Salesforce reads or writes. This is the emergency stop • Credentials: production uses a new SF_LFD_ triple and never falls back to SF_DEMO_. Sandbox mode uses SF_DEMO_* only, so setting the target back to sandbox is a complete rollback on its own. • Deal calculator: unchanged. onCalcApproved and sfDemoProxy's calc actions keep SF_DEMO_* with no target, so their login still refuses anything that isn't a sandbox. • Sessions: one per org (target + client + instance). • Tickets: record the org they were filed in. Every poller and every triage write (reassign, close, reopen) checks the ticket's org before touching Salesforce; a ticket from the other org is left alone, never tried against the wrong org. In production the pollers query only their own tickets, so sandbox tickets can't fill a batch. • Off: the NDA, Beta Terms, document and owner pollers skip, and a Slack filing gets a "Winston filing is paused" error instead of a retry loop. • Poller health: a run that checked nothing no longer stamps healthy, and a run that checked nothing because every open ticket is in the other …
#95
lfd: close the review todos — bell tests, quieter poller, honest alarms and controls
Nick RaffeyLegal Front Dooropened 2026-09-14merged 2026-09-148 files, +327 / −24
Merged
The five follow-ups from today's reviews of #88 / #91 / #94. None were blocking; four were quietly wrong. 1. lfd-notify.ts had no tests at all It decides who gets woken up, and the newest branch — a ticket that arrives already owned rings only its owner — had nothing holding it in place. New functions/scripts/notify-harness.cjs, 11/11, fake Firestore, no network. Covers both directions of that branch, the self-serve lanes, self-assignment by email and by Slack id, a roster member with no Winston account, and the at-least-once redelivery that must not flip a read bell back to unread. 2. lfdCaseOwnerPoll logged two junk warnings every 2 minutes ~1,400 a day, enough to bury a real one. It called intakeDeps(), which reads the Anthropic key and Ironclad creds the poller never binds or uses. Fixed with a narrow sfPollDeps() rather than by binding the secrets — the alternative silences the log by handing a Chatter poller credentials it has no reason to hold. lfdTriageAssign already built its own sfCreds for exactly this reason; this names and shares it. 3. The health alarm couldn't see a partial stall It fired only on errors === checked. The Chatter mirror skips a saturated 100-ticket chunk, so on a queue over one chunk that's 100 errors of 250 — never an alarm, and those tickets go stale silently and permanently. PollerOutcome now takes partialStall (not stalled — nda-signed already has a numeric field by that name, and the compiler caught the collision), set from chatterIncomplete. Winston has ~44 open tickets, so this is latent until the queue passes 100. Doing it now because nothing would announce that crossing — "revisit at 100" isn't a plan anyone gets reminded of. Wording followed: a partial stall previously read "failing on every ticket — 100 of 250 this run"…
#94
lfd: Chatter tabs — Case / Account / Opportunity
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-144 files, +28 / −23
Merged
Follow-up to #91. On the ticket, Chatter record choice is now tabs instead of a dropdown, in the order Case · Account · Opportunity, with Case open by default. • Same tablist style as the Split / Board / Map queue view toggle. • The Case tab still shows its count up to the close, for example "Case (2)". • Nothing else changes. Rows are newest first, the close cutoff applies, the empty and error lines are the same, Account and Opportunity are still read live when their tab is opened, and the requester view still has no Chatter.
#93
lfd: unread dots in the queue, ticket-counted bubble, and LFD as a standard nav item
Nick RaffeyLegal Front Dooropened 2026-09-14merged 2026-09-149 files, +125 / −35
Merged
Nick's three local commits, rebased onto current main (they were sitting unpushed on his machine while main moved on with #91 and #92). Rebased, not squashed, so the three stay separate. • lfd: Legal Front Door is a regular nav item, not an Admin one — moves the LFD nav item out from under the Admin header to sit with the standard modules. Access is unchanged: still an admin-granted premium module. • lfd: only ring the triagers for a request that arrives unassigned — a ticket that arrives already owned was auto-assigned by the Salesforce routing rules before any human saw it (the 08-21 intake pivot), so the owner gets their own "assigned to you" bell and ringing every other triager is noise they can't act on. Unassigned arrivals still ring everyone: that is the triage queue. • lfd: dot the unread tickets in the queue, and count tickets in the bubble — one lfdUnreadTicketIds set computed in App, shared by the sidebar bubble and the queue row dots, so the count can never point at tickets the queue won't show you. Keyed by ticket, not notification, so a ticket that was filed and then assigned to you is one thing to go look at rather than a 2-bubble over a single dot.
#92
lfd: an ESA with no opportunity says why it won't route
Nick RaffeyLegal Front Dooropened 2026-09-14merged 2026-09-143 files, +116 / −4
Merged
Follow-up to #88, from reviewing it. The gap 88 put the exact ESA variant on Agreement_Type__c so the Legal Contracting assignment rules stop sending every Winston ESA to the catch-all. Three of the four route on that value alone. ESA – Non-Government does not — its rule also reads Opportunity_Owner_Region__c, a formula off the Case's Opportunity, which #88's own description notes. The anchor picker deliberately offers accounts as an escape hatch for opportunity-anchored types (lfd-intake.ts: "Opps first …, accounts as the escape hatch"), and page-2 validation accepts them — the error text says so: "Pick the opportunity (or an account, if the deal isn't listed)." So an ESA filed that way has no Opportunity, the formula is blank, the rule can't fire, and the Case lands on the catch-all exactly as before, with nothing saying why. It's the most likely variant, listed first in the radio, reached by a supported path. Same for an opportunity-anchored ESA whose owner has no region set. Why not just fix the routing We can't, from here. Opportunity_Owner_Region__c is a formula, so it isn't writable, and guessing an Opportunity for the account would file the request against the wrong deal. What this does instead 1. Asks for the opportunity on the form. The ESA's anchor hint now says an ESA filed against the account alone won't route. This is the cheap fix — most requesters do have a deal and were simply never told it mattered. 2. Says it on the Case when they genuinely don't have one. routingGapNote() names the field, says where the Case will land, and says what to do about it. Same degrade, but never silently doctrine createLegalCase already applies to a refused picklist value. It lives in createLegalCase so the Slack filer and Chat 2.0 (sfDemoProxy) both get it, and…
#91
lfd: Account / Case / Opportunity Chatter picker on the ticket (live read), Chatter only up to the close
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-1420 files, +1889 / −113
Merged
A Chatter section on the ticket, directly under the Triage note: a "Chatter" dropdown for Account / Case / Opportunity (default Case), with that record's Chatter listed below it. Plus a "Show Chatter from" filter on the deal page: All / Opportunity / Linked Cases. • Moved out of the Timeline (Nick, 2026-09-14): the Timeline is lifecycle steps only again (created, assigned, replies, signature, closed). Every response-time number is unchanged, and Chatter can no longer sit between two steps. • Case: the Chatter already mirrored onto the ticket (#72), newest first, each row "Post" or "Comment" · author · time. • Opportunity / Account: the Case's own Opportunity__c / AccountId, read live from Salesforce when picked. Nothing is stored and nothing is polled. A status line under the dropdown shows reading…, the Case has no Opportunity, no Chatter on the record, or an error with Retry. • Requesters no longer see Chatter. The section sits with the Triage note, which the requester view doesn't render. Before this change, requesters saw Case Chatter rows in their Timeline. That's intended: only lawyers use the Legal Front Door. • Deal page: filters the posts it already shows. Account isn't offered there, because deal data is synced from prod Salesforce and this endpoint's credentials are the Legal Front Door org. Only up to the close (Joe, 2026-09-14): on a closed ticket, the Chatter section shows Chatter posted up to the close time and nothing after, for Case, Opportunity and Account alike. • The cutoff is the latest close, so a ticket closed, reopened and closed again still shows what was said in between. • A closed ticket with no usable close time shows no Chatter, never everything. • A repeat close of an already-closed ticket no longer moves the close time. setRequestStatu…
#90
deal desk: Legal Front Door tickets on the deal drawer
Nick RaffeyLegal Front Dooropened 2026-09-14merged 2026-09-143 files, +191 / −1
Merged
Joins LFD tickets onto deals client-side (the useDealContracts pattern): opp match on the 15-char SF id prefix, account match on normalized name, badged "This opportunity" / "Account". Shows as a Legal tickets section under Contracts on the drawer Overview, rows opening the queue's TicketModal (full triage controls). Mounted only for admin/legal with the legalFrontDoor module, mirroring the rules gate on legalIntakeRequests. Sandbox→prod: no code change needed at cutover. Tickets currently anchor to the full sandbox, so the id tier only hits where sandbox ids still equal prod; the account-name tier carries the join until intake flips to prod (sandbox === false), at which point the id tier starts landing by itself. tsc --noEmit + build clean. Built from a clean origin/main worktree — no overlap with the uncommitted GenAI Opt-In WIP in the main checkout.
#89
lfd: board cards open the ticket modal in place
Nick RaffeyLegal Front Dooropened 2026-09-14merged 2026-09-142 files, +6 / −8
Merged
Clicking a card on the Board view switched you to Split to show the detail pane — losing your place on the board. The board now opens the existing TicketModal (the mobile popup, full triage controls included) right over the columns; Esc or clicking the scrim closes it back to the board. Selection still follows the URL so deep links and the back button work. Two edits in IntakeQueue.tsx (board onOpen no longer switches view; modal render condition includes desktop board) plus a stale comment fix in BoardQueue.tsx. tsc --noEmit clean.
#88
LFD ESA: ask the ESA type so the Case routes (Slack + Winston)
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-146 files, +241 / −35
Merged
Every Winston ESA landed on Flor. Prod's Legal Contracting record type offers the four ESA values the assignment rules route on, but not plain "ESA", which is what Winston filed. So Winston ESAs filed as "Other" and hit the catch-all. What changes • Slack form: the single ESA menu row stays (#73). Page 2 adds a required ESA type radio (Non-Government / Government / Financial Institutions / New Business) before the US / non-US question, on both papers. The exact picklist value (en dash) becomes Case.Agreement_Type__c. • Winston app (Chat 2.0): the ESA chip asks the same four as a follow-up. The ticket category stays "ESA", so filters and analytics group with Slack requests. • Winston ticket and Slack card: both show the variant through the existing agreementType display. • Salesforce insert: createLegalCase takes agreementTypeFallback, and the type degrades variant → "ESA" → "Other" within four inserts. BU_Full offers only "ESA", so the sandbox still files. • Forms opened before the deploy: a Slack page 2 without the new block files as before. An old esa::ESA – … token keeps its variant. • Ironclad generation is unchanged. It stays category-driven, and the recordType is still "Customer ESA". Prod routing this enables (entries read from prod 2026-09-14) • ESA – Government / Financial Institutions → Elisa Cooke • ESA – New Business → Amy Hochberger • ESA – Non-Government → Amy Hochberger only when Opportunity_Owner_Region__c is set (formula from the Case's Opportunity). Otherwise it still goes to the catch-all.
#87
lfd: knowledge refreshes every 15 minutes; a bare contract type is a request
Nick RaffeyLegal Front Dooropened 2026-09-14merged 2026-09-142 files, +55 / −1
Merged
Problems (from tonight's live testing) 1. Stale FAQ answers. loadKnowledge cached the sheet for the instance lifetime, and lfdIntakeProcess keeps a min-instance — a sheet update could sit unread for days. Tonight the FAQ kept pointing NDA askers to the retired Confluence Power Form after the sheet was already fixed. 2. "nda" answered from the FAQ. "i need a contract" → clarify ("which agreement?") → "nda" was classified as a question (the FAQ-titles hint biases one-word messages that way) and drew the stale FAQ row instead of the request flow. Changes • 15-minute knowledge TTL. The bucket copy is re-read after 15 minutes; a failed refresh serves the previous copy and retries next TTL instead of throwing at every caller. Sheet updates now land within upload + ≤15 min. • Bare-type backstop. A message that is nothing but a contract type ("nda", "an NDA please", "esa request": ≤4 words, no ?, no question word) is a request with that type pre-picked — in the DM root and in Q&A threads (where the clarify reply lands). "what is an nda" / "can we sign their NDA" stay questions. Deterministic, reuses requestTypeKeyMentioned.
#86
lfd: signed NDA lands in the thread once — the permalink message is the only share
Nick RaffeyLegal Front Dooropened 2026-09-14merged 2026-09-142 files, +43 / −13
Merged
Problem The signed-NDA poller delivered the executed copy twice (Case 00024777): the upload shared the file into the thread (first card), and the "fully signed" announcement carrying the file's permalink made Slack re-share the same file onto that message (second card). unfurl_links: false does not apply to Slack file permalinks — the prod thread proves it. Change • uploadFileToSlackWithEvidence gains { share: false }: the upload completes without a channel, so the file stays private until a message posts its permalink — which is exactly what the announcement does. One card, attached to the sentence that explains it. • The NDA poller uses it. postedToSlack now requires a permalink (without one, nothing would ever share the file), and the share/inThread evidence fields are null going forward — they described the old shared-upload flow. • The 2026-09-11 invisible-upload fix is preserved: the permalink in the message is still (now solely) the delivery. • doc-ready (ESA/DPA drafts) is untouched — it uploads without a permalink message, so it never double-posted.
#85
LFD DPA: one counterparty email for notices
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-144 files, +102 / −29
Merged
Same one-email treatment as the ESA (#83), for the DPA. What changes (DPA only; ESA unchanged) • DPA page 2 asks one email, labelled "Counterparty email for notices". It is still prefilled from the Salesforce primary contact. • That value is sent as both Ironclad's required Counterparty Signer Email role (role367b…) and counterpartyEmailForNotices. Both are required: "always" on the DPA schema. • dpaNoticesEmail() in ironclad-launch.ts does this: an explicit notices address if one was given, otherwise the signer email. Both the gate and the payload use it. • The separate "Counterparty email for notices" box is gone. • A Slack form opened before the deploy still shows that box: • blank: the one email is used; • a typed address: it is kept; • a malformed address: an inline error on that box. • The harness now refuses to run against a stale lib/ build.
#84
lfd: NDA type question removed — the Mutual NDA is the only NDA on our paper
Nick RaffeyLegal Front Dooropened 2026-09-14merged 2026-09-143 files, +9 / −7
Merged
Problem The NDA request form asks "NDA type" (Mutual / One-way), but BetterUp's paper has exactly one NDA — the Mutual NDA. The Ironclad workflow is literally "BetterUp Mutual NDA" and generates the mutual template regardless of the answer, so a One-way pick produced a mislabeled record over the same document. Change • Form: the nda_type radio is removed from the NDA page 2; "mutual or one-way" is removed from the conversational checklist. • Launch: nDAType: "Mutual NDA" is hardcoded at the wire in launchNdaWorkflow (the schema still requires the attribute), and ndaType is dropped from NdaLaunchInput — no caller can send anything else. Stale forms are unaffected: a pre-deploy view's nda_type value was already ignored at the launch layer now, and the field was pre-selected Mutual anyway. • Case description: reads NDA: Mutual, … explicitly.
#83
LFD ESA: remove AI questions; one customer email for notices
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-144 files, +177 / −230
Merged
Nick, 2026-09-13: remove both AI questions from the ESA Slack form, and ask one email that populates Email for Notices. What changes (ESA only; DPA unchanged) • AI questions removed. "Has the customer requested an AI due diligence review or questionnaire?" and "Is the customer aware the BetterUp platform contains AI?" are gone from page 2, the gate, the skip reasons and the payload. artificialIntelligence and customerAwareOfAi are required: "never" on the live V45 launch schema. customerAwareOfAi takes the template default (Yes), so the "Customer Unaware of AI" email to legal won't fire for Winston drafts. • One email. The separate "Contact email (email for notices)" box is gone. The Salesforce-prefilled signer email box is relabelled "Customer email for notices" on the ESA. Its value is sent as both Ironclad's required Customer Signer Email role and contactEmail (Email for Notices). • esaNoticesEmail() in ironclad-launch.ts does this: an explicit notices address if one was given, otherwise the signer email. • Forms opened before the deploy. A Slack form opened before the deploy still shows the old notices box: • blank: the customer email is used; • a typed address: it is kept; • a malformed address: an inline error on that box.
#82
lfd: ESA asks the customer address; renewal type removed (Case 00024767 MISSING_PARAM)
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-144 files, +386 / −186
Merged
Problem The first ESA launched with the self-serve switch on (Case 00024767) proved the switch works: it opened the Lifecycle section at Create. Ironclad then rejected the launch: MISSING_PARAM "missing required attribute(s)" param: ["renewalTermLength","counterpartyAddress"] The launch schema reports both as required: "never", but in the designer they're Required once their section is active. Codex read all 40 ESA questions: these two are the only Required questions Winston wasn't sending for its launches. Nick, 2026-09-13: "we need counterparty address", and "renewal type is not tied to any clauses, so that part is not necessary — just term amount and term type". Change • ESA page 2 adds the customer address: street (multi-line), city, state/region (optional), postal code, and country (pre-filled "United States"). Renewal type and its "Other" description are removed, and the initial term (how many, plus years or months) is required again. • Payload. The address is sent as counterpartyAddress in the NDA lane's production-proven shape, { lines[], locality, region, postcode, country }, and written to the Case description. A missing street, city, postal code or country blocks the launch with the new skip reason no-address, using the same messages as the other skips. • Renewal type is no longer sent, so "What will be the term of the renewal…" (Required only for Auto-Renew or Optional Extension) never applies. The dead renewal-type helpers are removed. • Diagnostics. launchRejectionFields now reads Ironclad's param field (a JSON-encoded array string), so a MISSING_PARAM names its fields. Case 00024767's message named none. • Docs. A dated update in docs/ironclad-launch-prereqs.md.
#81
lfd: DPA fills the draft's signer name (signerNamePrinted)
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-142 files, +23 / −6
Merged
Problem Same as #80, for the DPA. The customer "Name:" line on the generated DPA is a DocuSign signer field that only fills at signing, so the draft the AE sends for redlining shows it blank. Ironclad (done, published 2026-09-14 01:02Z) • New property. The DPA template has a plain Text property, signerNamePrinted. The document prints it when it has a value and falls back to the signer field when it's empty. • Title needs nothing. The DPA's "Title:" line already prints the plain Counterparty Signer Title, so there's no signerTitlePrinted on the DPA. • Launch form. API-verified: signerNamePrinted is the only launch-form change. Change • DPA launch. The trimmed signer name is also sent into signerNamePrinted. It's never sent blank, and signerTitlePrinted is never sent; Ironclad rejects unknown attributes. • Rejection diagnostics. signerNamePrinted is on IRONCLAD_ALWAYS_REQUIRED.dpa.sent.
#80
lfd: ESA fills the draft's signer name/title (signerNamePrinted / signerTitlePrinted)
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-142 files, +48 / −0
Merged
Problem On the generated ESA, the customer "Name:" and "Title:" lines in the signature block are DocuSign signer fields that only fill at signing, so the draft the AE sends for redlining comes back with both blank. The Customer Signer role owns the Counterparty Signer Name and Title properties, so wherever they're placed they render as signer fields, and a condition can't reference them. Ironclad (done, published 2026-09-14 00:36Z) The ESA template now has two plain Text properties, signerNamePrinted and signerTitlePrinted. The document prints them when they have a value and falls back to the DocuSign fields when they're empty, so manually launched ESAs are unchanged. The same publish added winstonSelfServe, the switch for #79. Change • ESA launch only. The trimmed signer name and title are also sent into signerNamePrinted and signerTitlePrinted. They're never sent blank, and never on the DPA, whose schema has neither field; Ironclad rejects unknown attributes. • No config needed. The keys were read from the live published schema, so there's no env gate. • Rejection diagnostics. Both keys are on IRONCLAD_ALWAYS_REQUIRED.esa.sent.
#79
lfd: ESA self-serve contract terms — renewal type on page 2, env-gated self-serve switch
Joe ChiusanoLegal Front Dooropened 2026-09-13merged 2026-09-134 files, +328 / −18
Merged
Stacked on #77 (joe/lfd-esa-ai-questions). Merge #77 first; GitHub retargets this PR to main when that branch is deleted. Problem Generated ESAs come back with a blank effective date ("entered into as of (the "Effective Date")") and a blank initial term ("until the later of: (i), or (ii)…"). Cowork read the designer and workflow IC-5098 on 2026-09-13: • The document is fine. The contract's tags are bound to the right properties (Effective Date, Initial Term Length). • The questions are asked too late. Those properties, plus Renewal Type (which gates the term), are in the Lifecycle section, which is "Starting at Review when Legal Review = Yes". An API launch happens at Create, so Ironclad silently drops them (200, no error). IC-5098 stored none of them, and the document, generated at Create, can never show them. • It has to be the same questions. Renewals and expirations are computed from those same properties, and Ironclad has no formula field to fall back from one property to another. Agreed fix: one template, no duplicate Ironclad (admin, separate from this PR): 1. Add a Create-step Yes/No question "Winston self-serve (system use only)", default No, Locked. 2. Change the Lifecycle section to "Starting at Create when Legal Review = Yes OR Winston self-serve = Yes". OR conditions already exist on this template. 3. While there, also Lock "Sales - DO NOT TOUCH". It's currently unlocked. Manual launches are unchanged at launch. This PR, safe to deploy before the Ironclad change: • Page 2. ESA page 2 adds a required Renewal type (the schema's five values, nothing pre-selected) and a description box for Other. The initial term becomes optional in Slack and is required server-side unless the renewal type is Evergreen, matching the template's own "Renewal Type is not E…
#78
lfd: DPA launch never sends salesforceAccountId (the DPA schema has no such attribute)
Joe ChiusanoLegal Front Dooropened 2026-09-13merged 2026-09-132 files, +20 / −3
Merged
Problem Every DPA filed against an account fails to generate. Prod, 2026-09-13 21:28Z, Case 00024762: POST /workflows (DPA) 400: {"code":"INVALID_PARAM","message":"non-existent attribute(s) specified: salesforceAccountId","param":"attributes"} attributesFor() sends salesforceAccountId for both templates, but only the ESA launch schema has that attribute. The live DPA schema, re-read the same day, has none. The requester sees "The DPA couldn't be generated" and legal drafts it. Change • salesforceAccountId is now sent only on the ESA. The NDA lane is untouched (its schema has the attribute). • Harness: the check that asserted salesforceAccountId on the DPA now asserts it is absent, and that the DPA payload is exactly the DPA schema's always-required set.
#77
lfd: ESA asks both AI questions; copy says legal still approves generated documents
Joe ChiusanoLegal Front Dooropened 2026-09-13merged 2026-09-135 files, +223 / −53
Merged
Based on main (#76 is merged; rebased onto Nick's 0360b66 — effective-date timestamp + signer pre-fill). Problem A read-only pass over the live Ironclad designer on 2026-09-13 found three problems with what Winston does for self-serve ESAs and DPAs: 1. "Has the customer requested an AI due diligence review or questionnaire?" (artificialIntelligence) is required on every ESA. The designer shows it Always, Required, with no condition, even though the API launch schema calls it required: "conditional". Winston didn't ask or send it. It's the most likely reason behind today's ESA 400, which e758548 couldn't identify because the error message only echoed the request. 2. "Is the customer aware that the BetterUp Platform contains artificial intelligence?" (customerAwareOfAi) was removed earlier today as driving no logic. It does drive logic: a No triggers "Customer Unaware of Artificial Intelligence", the ESA's only automated email, which goes internally to legal. The schema defaults it to true, so leaving it out silently answered Yes and that email could never fire. 3. Legal approves every generated ESA and DPA. Both workflows run Create → Review → Sign → Archive, and Review is a Legal approval step on every workflow. The Slack note, the launch and ready messages, the Case "Drafted" comment and the triage message all said legal was only involved on redlines. Signature sending is manual on both, so nothing reaches the counterparty automatically. Change • Two new questions. ESA page 2 (generate lane only) adds two required Yes/No radios after the contact email, doc_ai_diligence and doc_ai_aware, with nothing pre-selected. • Sent to Ironclad. Both answers go as JSON booleans, so a No arrives as false. They're listed in IRONCLAD_ALWAYS_REQUIRED.esa.sent and added to the Cas…
#76
lfd: DPA/ESA ask for the customer legal name (pre-filled from the account)
Joe ChiusanoLegal Front Dooropened 2026-09-13merged 2026-09-132 files, +321 / −10
Merged
Problem The self-serve DPA and ESA put the counterparty name on the contract straight from the Salesforce Account Name. That isn't a legal entity name. BU_Prod has no legal-name field on Account, and account names are often shortened or annotated. Measured 2026-09-13 across the 732 accounts behind open opportunities: • 42 carry brackets, dashes, FKA or test markers that would print on the contract as-is, for example "Everpure (FKA Pure Storage)" and "Army National Guard [ARNG]". • About half have no Inc / LLC / Ltd. Many of those are fine (universities, government bodies), but some are short forms of a longer legal name. • The ZoomInfo / D&B company names aren't legal names either (Aon → "Aon Hewitt"), so there's no field to pull one from. Change • New field. Page 2 now opens the document fields with a required Customer legal name (ESA) or Counterparty legal name (DPA). It only appears when Winston generates the document (our paper, generation switched on). • Pre-fill. Picking the opportunity fills the box with the account name, and the AE corrects it to the exact legal entity. A name typed before picking is kept. Re-picking the same opportunity keeps edits; picking a different one resets to that account. • Mismatch guard. If the opportunity changes and the AE submits before the box refreshes, Slack shows an error on the box asking them to re-pick. The filer runs the same check before launching and hands the request to legal on a mismatch. • Where the name goes. The typed name is sent to Ironclad as counterpartyName, added to the Case description ("Legal name on the contract: …") and saved on the ticket (docLegalName). • Old forms. A page 2 opened before this deploy has no box and falls back to the account name, which is today's behaviour.
#75
lfd: executed NDA — link the Slack copy by permalink, record where Slack shared it
Joe ChiusanoLegal Front Dooropened 2026-09-12merged 2026-09-134 files, +195 / −12
Merged
What Nick saw (DM 9/11) > The final NDA in the self-serve workflow is pushing the NDA to the Case (good) but it's not sending back to the user in the Slack thread. We tried this once and it did, then some more changes broke that. The signed NDA should appear back in that thread. What the data says All three self-serve NDAs that reached "signed" (Cases 00024725, 00024735, 00024753) carry ndaSignedDocInSlack: true — Slack accepted every completeUploadExternal for the requester's DM + thread_ts. So the code path ran and the API said ok; what nothing recorded is where Slack put the file. That is the gap this closes, and it also gives the requester a link that works regardless. Change • uploadFileToSlack returns { ok, permalink, shares } instead of a bare boolean — permalink and shares come straight from Slack's files[0] in the completeUploadExternal response. One log line per upload: asked <channel>/<thread_ts> got <channel>:<ts>/<thread_ts> — if those disagree, the thread was ignored, and the next signed NDA tells us so. • The NDA poller stamps ndaSignedDocPermalink and ndaSignedDocShares on the ticket, and the "fully signed" line now reads Here's <NDA-signed.pdf> — it's also attached to the Salesforce Case with the permalink, so the copy opens from the thread even if the share itself rendered at the DM root or in the Files tab. • The attachment relay (requester → legal thread) keeps its boolean via .ok. No behaviour change there.
#74
lfd: NDA "Provide me with the Word template" lane; NDAs file without an account
Joe ChiusanoLegal Front Dooropened 2026-09-12merged 2026-09-1312 files, +990 / −84
Merged
What Nick asked (DM 9/11) > 6. Winston can hand out a Word version of the NDA form. There are some circumstances where an AE wants JUST the WORD template of the NDA — that's it. Add this as an option on the second page of the modal under "how should this go out" — 'Provide me with the word template' — that should create a self serve ticket and provide them with the template, then the ticket should close once it's provided. > 7. NDA can be launched without an account attached, for HR and procurement asks. The NDA account option should be optional. Change Word template (page 2, "How should this go out?"). Third radio: Provide me with the Word template (nothing else needed). Winston files the ticket and the Case exactly as today, posts BetterUp's .docx into the requester's thread, then closes the ticket itself ("Closed by Winston — Word template provided", selfServe: "nda-template") and the Case (CaseComment Word template provided by Winston — self-serve, closed). The triage card's thread gets a one-line FYI; no lawyer is DM'd, nothing is generated in Ironclad, nothing goes to a counterparty. The requester's receipt and the modal both say the request is closed and to file a new one if the counterparty comes back with changes. Two things it refuses to do: it never closes on a promise. If the .docx isn't on file, the request files as an ordinary NDA for legal, the Case description says "template not on file in Winston; legal to send it", and the requester is told so. If the Slack upload fails, the ticket stays open, selfServe is cleared so a human can close it, and the requester is told legal will send it. The NDA counterparty fields are now optional at the Slack layer — the handout needs none of them and Slack can't hide inputs behind a radio without a view round-trip…
#73
lfd: request-type list — alphabetical, no "(Per P1 Guidance)", one ESA + one MSA, ESA asks US / non-US
Joe ChiusanoLegal Front Dooropened 2026-09-12merged 2026-09-138 files, +1469 / −230
Merged
> Stacked on #70 (base = joe/lfd-generate-dpa-esa): the ESA jurisdiction rides #70's attributesFor(). GitHub retargets this to main when #70 merges; the diff shown is this change only. What Nick asked (DM 2026-09-11, items 1–5) 1. Alphabetize the request-type list. 2. Delete the parenthetical on the security-questionnaire options. 3. Collapse the ESA options and MSA Review into two entries, ESA and MSA. 4. Remove MSA Review (that's ESA on third-party paper). 5. When ESA is picked, ask "US or non-US customer?" on page 2 — that decides which off-the-shelf version they get. Change • List order. Rows are alphabetical within each group (Contracts · Amendments · Questionnaires · Other; group order unchanged). Contracts now reads Beta Terms · Create Order Form · DPA · ESA · MSA · NDA. • Labels. The three questionnaires and AI Addendum drop " (Per P1 Guidance)" from the menu label. The Salesforce leaf keeps it — that is the picklist value, not ours to tidy. • One ESA, one MSA. The four ESA – … rows collapse to ESA (leaf ESA); MSA Review becomes MSA (leaf MSA), still counterparty paper by definition and never generated. Retired tokens (esa::ESA – Government, msa::MSA Review, msa::Other) resolve to the live row, so a stale page-1 modal or old offer button files instead of "this form lost its request type". Free-text classification still names the family; the "ESA – X → ESA" split is gone because there is nothing to split. • Jurisdiction. Every ESA (our paper or theirs — a lawyer reviewing their paper wants it too) gets a required radio on page 2, "Is the customer US-based?" (US customer / Non-US customer). Stored as esaJurisdiction on the ticket, written into the Case description ("Jurisdiction: US / non-US"), shown on the triage card as "ESA · US", and forwarded to the Iron…
#72
lfd: Salesforce Chatter on the Case shows on the Winston ticket
Joe ChiusanoLegal Front Dooropened 2026-09-12merged 2026-09-1310 files, +708 / −39
Merged
What Nick asked (DM 9/11) "Pull Salesforce Chatter into the Winston ticket." Change The 2-minute case-owner poll (lfdCaseOwnerPoll) now also reads the human Chatter on every open ticket's Case — TextPost / ContentPost / LinkPost and their comments — and stores the newest 100 on the ticket as sfChatter, HTML stripped (entities decoded, @mention names kept), in time order. The ticket detail pane's timeline renders each entry as Salesforce Chatter / Chatter comment with the author, time and body, in the same stream as the Slack relay. • Second, independent SOQL in the same pass. A feed read failing leaves the owner/status sync untouched; it is counted as chatterErrors (not errors) and reported to poller health under its own name, lfdCaseChatter, so "Chatter failing on every ticket" still rings without muddying the owner sync's alarm. • Written only when the stored window would change (id, body, author or time). Owner/status keeps its own write, exactly as before; Chatter is a second, separate write. • A chatter-only write touches none of sfOwnerId / sfCaseStatus / ownerSlackId, which is all lfdSfMirror and lfdNotifications diff on — proved in the harness, both handlers make zero calls and zero writes on it. • File and link posts with no words still appear as "<author> shared a file: <Title>" / "shared a link: <Title> <url>", so their comments keep a parent. • System feed rows (TrackedChange, ChangeStatusPost, CreateRecordEvent, CaseCommentPost) are excluded by type. Winston's own writes are CaseComments, so nothing echoes back. • No Firestore rules change: the entries live on the ticket doc itself, under the existing read rule. Cost: one extra SOQL per 100 open tickets per run. Cutover check (integration user reads Chatter) Verified in BU_Full tonight: a token from t…
#71
lfd: Feedback door on the greeting card + feedback entry point in every outgoing email
Joe ChiusanoLegal Front Dooropened 2026-09-12merged 2026-09-139 files, +737 / −6
Merged
What Nick asked (DM 9/11, items 8 + 9) > 8. Add a feedback mechanism, a fourth button option in winston called Feedback. not sure how this will be brought up by a user? wdyt about this? > 9. Put that same feedback entry point in the outgoing emails. How it gets brought up: the door is on every greeting card, so the entry point is wherever the person already is — no new surface to find. Two extras so nobody has to hunt for it: typing feedback / bug / suggestion at Winston gets the same button back instead of being classified as a request, and every outgoing email ends with a line that opens Winston's DM in Slack from the mail client. I'd leave it at that for the pilot: a rating + free text is enough signal, and it lands where legal ops already looks. Change Slack (lfd-feedback.ts, new; lfd-intake.ts). Fourth door Feedback (lfd_intake_door_feedback) opens a modal: What's it about? (Winston in Slack · Winston app · A request I filed · Something else), How's it going? (👍 😐 👎), Tell us more (required), Which request? (optional). Pre-ack validation inline, modal clears, the write rides the queue like page 2. The submission is written to lfdFeedback (userId, userName, email, about, rating, text, requestRef, channel, threadTs, source, createdAtMs, createdAt), posted as a card to config/legalTriage.feedbackChannelId when set, else the triage channel (loadTriageConfig now carries the field), and acknowledged in the person's thread. No config ⇒ still recorded, ack says so. Door click is in MODAL_OPEN_ACTIONS (inline off the trigger_id); a dead trigger gets a fresh Give feedback button. Door context copy mentions Feedback. ⚠️ The callback id is lfd_intake_feedback, not lfd_feedback_: calc-approval.ts only hands lfd_intake view_submissions to the intake responder and acks-a…
#70
lfd: self-serve DPA and ESA — generated from the Ironclad template, requester sends manually
Joe ChiusanoLegal Front Dooropened 2026-09-11merged 2026-09-137 files, +546 / −7
Merged
Ask (Nick, 2026-09-11) DPA and "MSA" need to be self-serve like the NDA, with one difference: nothing goes out. Winston generates the document from the Ironclad template and hands it to the requester, who sends it manually. The templates take only the counterparty name. "MSA" is the ESA (BetterUp Enterprise SaaS Agreement) — MSA Review in Winston means the customer's paper and never generates. What it does 1. Form. DPA or any ESA variant, on our paper, with the Ironclad lane available → docGenerate. No new fields: the counterparty name is the picked Account's name, already resolved at filing. Page 2 gets one context line saying the document will be generated and that the requester sends it. 2. Launch. After the Case is created, launchDocumentWorkflow POSTs /workflows with the DPA or ESA schema id and { counterpartyName, salesforceAccountId }. Schema ids from GET /workflow-schemas?form=launch (2026-09-11); the NDA row of the same read matched the id already in code. Same environment interlock as sending: prod, or IRONCLAD_LIVE_SEND=true, else refused before any call. The ticket is stamped docOutcome: pending and the requester is told the document is on its way. A failed launch says legal will draft it. 3. Delivery. New lfdDocReadyPoll (every 2 min, docOutcome == pending only): fetches the workflow, picks the draft (never a signature packet), downloads it, uploads it to the requester's thread, attaches it to the Case, sets the Case to Drafted with a comment, stamps docOutcome: ready + sfCaseStatus: Drafted (so the mirror posts nothing extra), posts an FYI to triage. Cancelled workflows and ~40 minutes of empty passes are terminal and tell both threads. 4. Ticket lifecycle is unchanged. Not selfServe: it routes by the Case rule, the lawyer gets the normal DM (now an FY…
#69
lfd: Reopen reaches Salesforce (both directions); a reopened ticket can be closed again
Joe ChiusanoLegal Front Dooropened 2026-09-11merged 2026-09-135 files, +588 / −10
Merged
What Joe saw (Case 00024751, 2026-09-11) Reopen in Winston did not reopen the Case in Salesforce. Prod log shows the consequence: reopened in Winston 20:11:42Z → owner poller read the Case still Closed → mirror closed the ticket again 20:12:05Z, posting "done — closed in Salesforce" to the requester. The reopen silently undid itself in 23 seconds. Change Winston → Salesforce. setRequestStatus now writes the Case before flipping the ticket on a reopen: Status In Progress (owned) / New (unowned) / Assigned, all present on the Legal Contracting record type (read from BU_Full), with Sforce-Auto-Assign: false and a CaseComment "Reopened in Winston by …". Only when the caller passes sfCreds (the Winston queue); the mirror never writes back. A refused PATCH returns 409 with the reason and the ticket stays closed — no flap. Salesforce → Winston. handleSfMirrorChange had a close branch but no reopen branch. Added: Case leaves a closed Status while the ticket is closed → ticket reopens (owned → in_progress, unowned → new), requester told "reopened by Salesforce". No loop with the Winston path: that one writes the Case first, so by the time the poller reports it open the ticket already is. Two adjacent defects fixed in the same change: 1. The close branch skips the Case PATCH while sfCaseClosedByWinston is stamped, and nothing ever cleared it — so reopen → close again never reached Salesforce. Every reopen now deletes the stamp. 2. loadTriageConfig never copied sfMirrorSilent out of config/legalTriage, so the silent-backfill switch that four mirror branches check could not be turned on. Carried through now. ⚠️ Nick: if that flag is currently true in the prod config doc expecting it to be inert, the mirror will go silent on deploy — worth a glance.
#68
lfd: receipt stays in the thread the request was launched from (when that thread is nobody's yet)
Joe ChiusanoLegal Front Dooropened 2026-09-11merged 2026-09-132 files, +291 / −14
Merged
> Draft, pending Nick's call — this partially revisits the 2026-09-08 "one thread per request" decision (46a5901). Joe is raising it with Nick; opened now so it's ready either way. What Joe saw "hey winston, I need to create a case" in a thread → Winston posts the doors in that thread → form → the ✅ receipt lands as a new root message in the DM. The request's home thread (relay, status, NDA outcome) is one message below where the requester started, and the original thread is left holding only the doors card. Why it's like that today 46a5901 made the receipt always a new root: the relay is keyed by Slack thread (lfdThreadIndex), a thread belongs to one ticket, and recordIntake overwrites the requester entry — so a second request filed from the same thread used to hijack the first ticket's conversation. Change (functions/src/lfd-intake.ts only) • New launcherThreadIsFree(db, channel, threadTs): true when the launcher thread has no index entry, or only the offer / qa markers (which the requester entry is designed to replace — same graduation as Ask-a-human). False for requester / assignee entries, for no thread (DM root), and on a read failure. • Free → the receipt posts into the launcher thread and the ticket's slackThreadTs is that thread. • Not free → the receipt is its own root, exactly as today. Nick's second-request case still gets a fresh thread. • The retired "Open the form" button reads "the receipt is just below, in this thread" vs "see its thread below" accordingly. Nothing else moves: the receipt's copy, the relay anchor write, the assignee DM, the mirror.
#67
lfd: assignee DM shows a long question once, without nested quote marks
Nick RaffeyLegal Front Dooropened 2026-09-11merged 2026-09-111 file, +23 / −3
Merged
Two cosmetic defects in the assignee DM (assigneeDmText, functions/src/lfd-triage.ts): 1. A question longer than 100 chars was printed twice — the clipped subject in bold, then the full body — because the "show subject when it differs" test was a bare !==, and a question's subject is always the description clipped with an ellipsis. Now the subject gets its own line only when the body doesn't start with it. Requests (short subject, long description) are unchanged. 2. A requester who pasted a Slack quote had their > (&gt; on the event) wrapped in Winston's own block quote — a quote inside a quote. Leading quote markers are stripped per line before quoting.
#66
lfd: reassignment hands the new lawyer the conversation so far (Slack), retires the old relay thread
Joe ChiusanoLegal Front Dooropened 2026-09-11merged 2026-09-111 file, +144 / −0
Merged
Joe (2026-09-11): when a lawyer and a requester have been talking on a ticket and the lawyer reassigns it, the new assignee needs the full conversation when they're notified — in Slack and in Winston. Winston already has it: the ticket pane renders the relay transcript (messages[]) as the Conversation section for whoever opens the ticket, and the #63 assignment notification opens that ticket. No change. Slack did not: the new assignee's DM (dmAssignee) is a fresh thread with the original ask and metadata only. The history sat in the previous lawyer's DM thread and the requester's thread.
#65
lfd: triage card carries the Winston ticket link
Nick RaffeyLegal Front Dooropened 2026-09-11merged 2026-09-111 file, +3 / −0
Merged
The #legal-triage card now has a Winston: field with the deep link to the ticket (/legal-front-door/:ticketId), next to the Case link. Triagers had to hunt the queue for the ticket behind a card. The assignee DM already carries this link (#62); the card didn't (Nick, 2026-09-11 screen-share).
#64
lfd: "More" status menu was clipped to the list card — portal it out
Joe ChiusanoLegal Front Dooropened 2026-09-11merged 2026-09-111 file, +37 / −3
Merged
In the Legal Front Door Split view, the More status menu was cut off at the bottom of the list card. With only a couple of tickets the card is short, so only Open / New / Assigned / Unassigned showed; Awaiting reply / Closed / All were hidden until the refine (filter) bar opened and made the card taller. SplitQueue.tsx wraps the list in overflow-hidden (for the rounded corners) and rendered the menu as an absolute <ul> inside that card, so the card clipped it.
#63
lfd: notification bubble on Legal Front Door — new requests ring triagers, assignments ring the owner
Nick RaffeyLegal Front Dooropened 2026-09-11merged 2026-09-116 files, +221 / −6
Merged
A count bubble on the Legal Front Door sidebar item, plus rows in the existing bell, when a legal request comes in or is assigned to you (Nick, 2026-09-11). • Event — Who gets it — Row text • New ticket (request or question) — every triager in config/legalTriage.triagers, except the requester — Nick Raffey filed · Does ESA Section 6 cover… • Owner set or changed — the new owner, unless they assigned it to themself — Sasha Nichols assigned you · Loblaw has an active contract… Click a row → /legal-front-door/<ticketId>. Opening a ticket by any route (queue click, Slack deep link) marks its notifications read, so the bubble clears for what you've already looked at. "Mark all as read" in the bell clears the rest. Reuses the existing bell end to end — notifications collection, useNotifications, dropdown, markNotificationRead / markAllNotificationsRead — rather than a second system. • Functions: new lfdNotifications trigger (onDocumentWritten on legalIntakeRequests) → lfd-notify.ts. Recipients resolve email → Firebase uid via /users (no Winston account ⇒ no bell). Deterministic doc ids + create() so at-least-once redelivery is a no-op and can never flip a read notification back to unread. In-app only: Slack already has the triage card and the assignee DM. • Web: NotificationType gains lfd_request / lfd_assigned, targetType gains lfdRequest; NotificationItem renders the two new rows (DoorOpen / UserCheck) and falls back to the comment row for any unknown type; App deep-links and auto-marks read on ticket open; Sidebar computes the unread lfdRequest count for the nav bubble.
#62
lfd: assignee DM shows the whole ask + Winston link; Slack card says "In progress — self-serve"
Nick RaffeyLegal Front Dooropened 2026-09-11merged 2026-09-111 file, +40 / −9
Merged
Two small changes to lfd-triage.ts, one commit each. 1. Assignee DM: full question + link into Winston The "You've been assigned a legal question" DM clipped the subject to 150 characters and gave the lawyer no way into Winston. Now: You've been assigned a legal question: > Loblaw has an active contract running to March 2028 and wants 5 more licenses on a > separate order form … can it be aligned to end with the original contract in March 2028? Type: Question Requester: @Nick Raffey Filed: Today (today) Winston: open this question in Winston ← /legal-front-door/<ticketId> Thread: open the conversation Reply path: reply in this message's thread — … [Mark closed] • Full ask quoted (subject + description when they differ; clipped at 2200 chars for Slack's 3000-char section limit). • Deep link opens the queue's Split view on that ticket. Host from DASHBOARD_URL, same winston-legal default as the other Slack links. • Close/reopen re-renders use the same builder, so text and link survive status changes. 2. Slack triage card matches the web after #60 57 had the card read 🖊️ Out for signature for an unowned self-serve ticket; #60 moved that moment onto the web timeline and kept the status word "In progress" everywhere on the web. The card was the one surface still saying something else. Now ⏳ In progress — self-serve. The self-serve context line ("Winston closes this once every party has signed") is unchanged.
#61
lfd: "Ask a human instead" routes to a lawyer instead of manual triage
Nick RaffeyLegal Front Dooropened 2026-09-11merged 2026-09-112 files, +113 / −13
Merged
Clicking Ask a human instead under a Winston answer filed the ticket with no routing at all, so every rejected answer landed in the queue as New · Unassigned (tonight's "Does ESA Section 6 cover trust portal access…"). The stored decision was an answer, which carries no suggestedOwner, and the handler passed null on purpose. Now it routes the way legal would by hand: 1. The cited FAQ row's Escalation Path. Legal's own "who owns this when the sheet isn't enough". Compound values read left to right, primary first; the first segment matching the roster uniquely wins. Checked against the live sheet + roster: Sasha & Amy → Sasha Nichols, Karen Lawson / Elisa Cooke → Karen Lawson, Flor → Flor Z Aparicio, Sabba Mirza / Security team → no match → step 2. 2. Otherwise the brain, forced to escalate. New forceEscalate option on decide(): same call an unanswerable question gets, told the asker rejected the answer, action coerced to escalate, owner from the Routing Matrix (or the row's escalationPath, already in the FAQ JSON it sees). 3. Neither ⇒ manual triage, exactly as before. Routing never throws — a failure degrades to the old behaviour, never a lost click. fileTicket is unchanged: an owner ⇒ assignRequest (card update, assignee DM + relay anchor, "your question is with X"); none ⇒ the generic receipt.
#60
lfd: "Out for signature" is a timestamped event on the ticket timeline; status stays In progress
Joe ChiusanoLegal Front Dooropened 2026-09-11merged 2026-09-116 files, +49 / −19
Merged
Per Joe (2026-09-11): the self-serve NDA ticket keeps In progress as its status word; what legal needs is a dated event on the ticket showing the contract went out for signature. The ticket timeline (detail pane, "Timeline" section) now shows the NDA lifecycle: • Event — Actor — When — Detail • Out for signature — Winston · Ironclad — ndaLaunchedAt — NDA sent to the signer · workflow id • Fully executed — Ironclad — ndaSignedAt — signer count · executed copy on the Case • NDA cancelled in Ironclad — Ironclad — ndaCancelledAt — Nothing was signed Each row carries the date/time and the wait since the previous step, like every other timeline event. All three come from stamps the launch path and lfdNdaSignedPoll already write — nothing new is stored, so tickets already launched get the event retroactively. The #57 chip override (Salesforce status in place of "In progress" on the card/modal) is removed so the status word is the same on every surface.
#59
sync: commit the Cloud Scheduler → GitHub workflow_dispatch relay (live since 08-28)
Nick RaffeySync jobsopened 2026-09-11merged 2026-09-112 files, +98 / −0
Merged
Puts the source for two functions that have been running in prod since 2026-08-28 onto main: syncDispatchDealDesk (every 15 min) and syncDispatchLegalData (every 30 min). Until now they existed only as an untracked file plus 37 uncommitted lines of index.ts on Nick's laptop. A full firebase deploy --only functions from main aborts in non-interactive mode: Firebase sees two prod functions with no matching source and refuses to guess whether to delete them. The #57 deploy hit this tonight and had to name functions explicitly. With this merged, the source matches prod again and full deploys work.
#58
lfd: NDA fields, record notes and Order Form subject/details are required
Joe ChiusanoLegal Front Dooropened 2026-09-11merged 2026-09-111 file, +16 / −19
Merged
Every "(optional)" label Joe saw on the NDA form is gone — Slack renders that label for any input with optional: true, so the flag comes off: • Field — Was — Now • Counterparty legal name, Signer full name, Signer email, Registered address — street, City, State / region, Postal code, Country — optional (enforced at submit for Send now only) — required — Have legal review it first asks for them too • "Anything else for the record" (NDA and Beta Terms lanes) — optional — required • Subject and Details on Order Form — optional (subject defaulted to "Order Form — <deal>") — required; the default stays as a fallback only The send-now NDA_REQUIRED check is kept as the belt.
#57
lfd: self-serve NDA ticket stays open at "Out for signature" until executed
Joe ChiusanoLegal Front Dooropened 2026-09-11merged 2026-09-118 files, +153 / −40
Merged
The second half of #54 — its squash-merge took the Case-side commit only, so this is the ticket side, re-based on current main. Without it a self-serve NDA still shows Closed in Winston while the Case reads Out for signature. • Moment — Winston ticket — Salesforce Case (already live via #54) • Launch — in_progress, unowned (Self-serve chip), shows Out for signature — Out for signature • Every party signed, executed copy attached — Closed (by Winston · NDA executed) — Closed • Workflow cancelled in Ironclad — Closed (by Winston · NDA cancelled) — Not required • lfd-intake.ts: the launch path sets the ticket to in_progress (was closed) and stamps sfCaseStatus: "Out for signature" on the doc so the app and the triage card read it immediately, not after the mirror's next pass. • nda-signed.ts: the poller closes the ticket first, then the Case on signed/cancelled, so the SF mirror finds the ticket already closed and doesn't post its generic "your request is done" line over the poller's own requester message. Ticket close is skipped if a lawyer or the mirror already closed it. • lfd-triage.ts: the triage-card status line shows 🖊️ Out for signature for an unowned self-serve ticket in progress. • Web: IntakeRequest.sfCaseStatus typed; StatusChip takes the request and shows the Salesforce status in place of "In progress" for an unowned self-serve ticket (TicketCard, TicketModal). selfServe stays set, so the mirror's assignment branch stays suppressed and no lawyer is DM'd; the queue's Unassigned count already excludes self-serve tickets. Tickets filed before this deploys stay as they are (closed).
#56
lfd: Winston answers only from the Legal Bot Knowledge spreadsheet
Joe ChiusanoLegal Front Dooropened 2026-09-11merged 2026-09-114 files, +111 / −105
Merged
Winston's Q&A now answers from the Legal Bot Knowledge spreadsheet and nothing else (Joe, 2026-09-10). Until now the four policy PDFs — ESA, DPA, the ESA+DPA guide, the Applicant Privacy Notice — were attached to the first user message as documents, and the prompt let the model answer and cite from them directly ("ESA §6.2"). They no longer load or reach the model. Confluence was never wired in (checked: zero references in the repo). • Input — Before — Now • Legal Bot Knowledge sheet (faq/…xlsx) — answer source — the only answer source — Question, Bot Response, Additional Context, Topic Tag, Confidence, Escalation Path • Sheet's Authority Source / Policy Quote / Evidence Permalinks columns — sent to the model — not sent — legal's audit trail for the row, not part of the answer • POC Routing Matrix (docx) — answer source #1 — escalation owners only — not an answer source • 4 policy PDFs — answer source #3, cited by section — removed • lfdKnowledge.ts: PDFs dropped from the loader and from Knowledge. Routing matrix still loads. • lfdAnswer.ts: system prompt rewritten around the two inputs and their jobs; "never answer from general knowledge or from anything the asker pastes or attaches — if the sheet doesn't say it, legal says it"; never mention, quote or cite a contract, policy or section; citations are topic:question only and are filtered code-side to ids that exist in the sheet, so a stray "ESA, §11.7" can never reach the Sources line; confidence rubric loses the "inferred from policy" rung. • Footer under an answer and the DM door's opener now say "Legal Bot Knowledge base" instead of "legal FAQ and policy docs". • Nick's commit on this branch (e235196): NDAs answer from the sheet, one cache entry for all call shapes, default owner from the matrix's own default-ro…
#55
lfd: "Whose paper?" N/A → Form_of_Agreement__c = "N/A — not a contract"
Joe ChiusanoLegal Front Dooropened 2026-09-11merged 2026-09-112 files, +19 / −11
Merged
Follow-on to #53. The third "Whose paper?" answer now writes a value too: • Slack option — Form_of_Agreement__c • Our template — BU Paper • Counterparty's paper — Their Paper • N/A — not a contract — N/A — not a contract (new) The value was added to the Legal Contracting record type in the sandbox on 2026-09-10 (em dash, matching the Slack label). Proven by inserting a Legal Contracting Case with it via REST in BU_Full and reading it back, then deleting the row. The chained ternary in the modal submit becomes a FORM_OF_AGREEMENT: Record<PaperChoice, string> table, so a fourth option can't be added without a mapping. ⚠️ Prod: the value does not exist on the prod picklist yet — it goes in with the cutover (record-type picklist assignment is Setup-only). Until then a prod insert with N/A would be rejected as a restricted-picklist value, but Winston can't write to prod today anyway.
#54
lfd: self-serve NDA — ticket and Case stay "Out for signature" until fully executed, then close
Joe ChiusanoLegal Front Dooropened 2026-09-11merged 2026-09-113 files, +83 / −34
Merged
The self-serve NDA no longer closes anything at launch. Case 00024735 showed the problem: created 23:58:28Z, closed 23:58:51Z, signed some time later — so both the Case and the Winston ticket read done for the whole time the NDA was out with the counterparty. Now, ticket and Case move together: • Moment — Winston ticket — Salesforce Case — Comment on the Case • Launch (Winston sends via Ironclad) — in_progress, unowned (Self-serve chip), shows Out for signature — Out for signature — unchanged — the Ironclad workflow pointer • Every party signed, executed copy attached — Closed (by Winston · NDA executed) — Closed — signer count; whether the executed copy is on the Case; workflow id • Workflow cancelled in Ironclad — Closed (by Winston · NDA cancelled) — Not required — nothing was signed; reopen if still wanted Both statuses are offered to the Legal Contracting record type in the sandbox and production (checked on the record-type picklist, not the field). The close happens after the executed copy is attached, so the Case closes with the document on it. • sf-demo.ts: new setLegalCaseStatus(creds, caseId, status, comment?) — comment first (survives a Status reject), then PATCH with Sforce-Auto-Assign: false (#48). closeLegalCase now delegates to it; no caller changes. • lfd-intake.ts: the launch path sets the ticket to in_progress and stamps sfCaseStatus: "Out for signature" on the doc (so the app and triage card read it immediately, not after the mirror's next pass), then calls setLegalCaseStatus(…, "Out for signature", …) where it used to call closeLegalCase. • nda-signed.ts: the signed branch closes the ticket, then the Case once execution is confirmed and the copy attached; the cancelled branch closes the ticket, then marks the Case Not required. Ticket first, Cas…
#53
lfd: "Whose paper?" lands on Form_of_Agreement__c for every request type
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-111 file, +11 / −3
Merged
The page-1 "Whose paper?" answer now lands on Case.Form_of_Agreement__c for every request type. Until now only Beta Terms wrote it — every other Case answered the question and never recorded it. • Answer — Form_of_Agreement__c • Our template (ours) — BU Paper • Counterparty paper (theirs) — Their Paper • N/A (questionnaires) — not written Both values are offered to the Legal Contracting record type in the sandbox and production (checked via the record-type picklist, not the field). BU Online Terms isn't asked for and isn't written. Beta Terms is unchanged — ours → BU Paper is what the BU18 auto-send flow keys on, and theirs → Their Paper keeps it from firing, exactly as before; the special-case lines are just subsumed by the general one. MSA reviews are coerced to counterparty paper by formShape, so they land as Their Paper. One-line change in the createLegalCase call; tsc clean. The integration user already writes this field (it does for Beta Terms today), so no Salesforce change is needed.
#52
lfd: quote picker reads the stashed opportunity (same fix as the signer picker)
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-111 file, +28 / −8
Merged
What broke Testing #45 in the sandbox: opportunity JC Test Acct - NB - 1yr - DD chosen, two CPQ quotes on it (Q-26001 primary, Q-25995), and the Quote picker says No result. Ruled out the data side first: the integration user has read on SBQQ__Primary__c, SBQQ__Status__c and SBQQ__Opportunity2__c, and the picker's exact query returns both rows. So it's the same failure #36 fixed for the signer picker — Slack sent the block_suggestion without the page-2 selection in view.state, and the handler had no oppId to scope by. Fix Two lines, reusing #36's plumbing: • The page-2 anchor pick is dispatched for Order Form as well as Beta Terms (dispatchAction: shape.betaTerms || shape.isOrderForm), so handleIntakeAction stashes oppId in private_metadata the moment it's chosen — that handler already did this. • The quote options handler reads oppId from private_metadata first, the live view state second, and returns {options: []} if neither has it. tsc clean.
#51
lfd: one close path — web close notifies the requester and clears Mark closed
Nick RaffeyLegal Front Dooropened 2026-09-10merged 2026-09-103 files, +166 / −64
Merged
Closing a legal ticket now behaves the same from every surface. setRequestStatus (functions/src/lfd-triage.ts) is the close path; on a transition into closed it: • posts the requester's note in their own thread — ✅ Your request <subject> has been resolved and closed by <name>. Need anything else? Just message me. — the same words the Slack button always used; • strips Mark closed from the triage card and from the assignee's DM (chat.update, same shape triageBlocks already uses), replacing it with the 🗄️ Closed by <name> context line, so a ticket closed in the web queue can't be closed a second time from Slack; • on reopen (closed → new/assigned/in_progress) posts ↩️ Your request <subject> was reopened by <name> — legal will follow up here. and puts the DM button back. handleCloseClick (the Slack button) no longer posts anything to the requester itself — it authorises the click, calls setRequestStatus, and confirms back where it was clicked. So a Slack close and a Winston close are identical, with no duplicate requester post. "by X" resolves through the legal roster: a <@U…> mention (Slack click) by slackId, the Winston caller's email by the new optional TriageRosterEntry.email (already present on the Firestore entries; the web queue's "Mine" filter reads it). Unmapped actors fall back to the raw label. Note this is the closer, where the old Slack copy said by <ownerName> (the assignee) regardless of who clicked.
#50
lfd: closing a ticket from the Winston queue is announced in Slack
Joe ChiusanoLegal Front Dooropened 2026-09-10closed 2026-09-102 files, +63 / −9
Closed
When legal closes a ticket from the Legal Front Door queue, Slack now hears about it: • Requester — in their own thread: "✅ Your request <subject> has been resolved and closed by <name>. Need anything else? Just message me." — the same words the Slack card's Mark closed already uses, so a requester can't tell which surface legal used. • Assignee — in the DM thread the relay runs through: "🗄️ <subject> was closed from the Winston queue by <name>. The requester's been told; nothing further is needed here." Skipped when the assignee is the one who closed it, or when lawyer DMs are muted for test traffic. • Triage card — the context line already updated; it now shows the closer's roster name rather than their email. Three surfaces close tickets and all three go through setRequestStatus. The Slack card (handleCloseClick) and the Salesforce-side close (handleSfMirrorChange) each post their own requester message after calling it. The Winston queue (lfdTriageAssign) called it and nothing else — so it closed the Salesforce Case, refreshed the card line, and told nobody.
#49
functions: deploy map — which exports to redeploy per source file
Nick RaffeyPlatformopened 2026-09-103 files, +241 / −1
Open
• functions/scripts/deploy-map.mjs (Node, no deps): parses src/index.ts, finds every export const X = on...(, attributes a module to an export only when the export's top-level statement (or a local helper it calls) references a symbol imported from that module, then follows relative imports transitively. Comments are stripped before matching. • node scripts/deploy-map.mjs → table export → files • node scripts/deploy-map.mjs src/lfd-intake.ts src/lfd-question.ts → firebase deploy --only functions:lfdNdaSignedPoll,functions:slackCalcAction,functions:lfdIntakeProcess • --md → markdown table for the doc • npm run deploy-map in functions/package.json • functions/DEPLOY-MAP.md: generated table for today's tree, usage note, and the hard warning about lfd-intake/lfd-question/lfdAnswer. Today an lfd-intake.ts fix was deployed to lfdSlackEvents + lfdProcessInbound, but that code path runs inside lfdIntakeProcess (onDocumentCreated lfdIntakeInbound/{eventId}) and slackCalcAction (modal opens / view submissions), so the fix was not live for hours. We need a reliable answer to "I changed file X — which exported functions must I redeploy?".
#48
lfd: Case PATCHes send Sforce-Auto-Assign: false (reassignment was being reverted by the routing rule)
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-101 file, +14 / −2
Merged
What broke The first reassignment from the Legal Front Door after #47 merged — Case 500hG00000AlmD7QAJ, 2026-09-10 22:48:06Z. CaseHistory shows, in the same second, by the integration user: Owner Nick Raffey → Joe Chiusano (the PATCH) ownerAssignment Joe Chiusano → Isha Ranjan (the Case Routing rule, NDA entry) A REST update with no Sforce-Auto-Assign header falls back to the org's default assignment behaviour, and here that re-ran the routing rule on the update and handed the Case straight back to the rule's owner. reassignLegalCase's read-back caught it and the queue showed "Salesforce refused the reassignment: … owner did not change (now 005Pd00000DqIpNIAV)" — so nothing diverged, which is the fail-safe working. But nothing reassigned either. Fix Both Case PATCHes now send Sforce-Auto-Assign: false: • reassignLegalCase — the fix for the above. • closeLegalCase — same exposure: a status update must not re-run the rule and move the Case to someone else as it closes. Not observed yet, closed pre-emptively. Two header lines and comments; tsc clean.
#47
lfd: reassign Case-backed requests from the Legal Front Door (individuals only, Salesforce first)
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-106 files, +116 / −13
Merged
Legal can now assign or reassign a Case-backed request from the Legal Front Door queue, and the change lands on the Salesforce Case immediately. Until now the Assign control was questions-only for requests; their owner came only from Salesforce's assignment rules, mirrored in on the 30-minute cron. Salesforce stays the system of record. The order of operations is the whole design: 1. assignRequest writes the Case owner to Salesforce first — new reassignLegalCase in sf-demo.ts: PATCH OwnerId, read it back, fail loudly if it didn't take. 2. Only on success: the existing Firestore write + Slack side effects (assignee DM, relay anchor, requester notification, card update). 3. Then sfOwnerId / sfOwnerName are stamped on the doc so the next mirror pass sees no diff and doesn't revert anything. If Salesforce refuses, nothing changes on either side and the queue shows the reason — lfdTriageAssign now answers 409 with the message instead of the generic 404. Individuals only. The target must be a roster member with a Salesforce user id (005…). Queues are refused at the write path rather than half-supported: a queue owner is exactly what the mirror holds sfOwnerId back for, and the roster carries no queue ids. The web panels offer only Salesforce-mapped members for Case-backed tickets; a roster with nobody mapped says so instead of showing an empty select. No loops. The mirror's own path (triagedBy: "salesforce") never writes back to Salesforce. And because the sfOwnerId stamp itself fires handleSfMirrorChange, that handler gets an already reflected guard — Winston already shows this owner ⇒ no-op — so the assignee and requester aren't DM'd twice for one assignment. Self-serve lanes (NDA via Ironclad, beta terms via the BU18 flow) stay unassignable — no human owner by des…
#46
case-mirror: poll Salesforce every 15 minutes instead of 30
Joe ChiusanoLegal Front Dooropened 2026-09-10closed 2026-09-152 files, +6 / −3
Closed
Cron on legal-case-mirror.yml goes from /30 to /15. README updated to match. No code change. A Case owner change in Salesforce only reaches Winston (ticket owner + assignee DM) on the next mirror run, so the interval is the ceiling on that delay. Measured today on the demo sandbox: Case 00024728 was reassigned at 21:47Z, ten minutes after the 21:37Z run, and sat unreflected until the next one. Halving the interval matches deal-desk-sync.yml.
#45
lfd: "Create Order Form" request — opportunity + quote picker → Case.Quote__c
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-102 files, +162 / −11
Merged
A new Winston request type, Create Order Form, that collects the Opportunity, a Quote picked from that opportunity's CPQ quotes, and optional Details, and files a Legal Contracting Case with Agreement_Type__c = "Order Form" and the quote on Case.Quote__c. • Opportunity-anchored, so the existing #44 picker applies unchanged. • Quote is an external_select scoped to the opportunity picked above — the options handler reads the picked opp off the modal's state in the block_suggestion, exactly as the Beta Terms signer picker scopes to the account. Primary quote first, labelled Q-12345 · Primary · <status>. Nothing picked yet ⇒ no options, and the placeholder says why. • On submit the picked quote is re-checked against the opp before it's stamped, so a stale option from an earlier opportunity selection is never filed. Left blank, the opp's primary quote is used. • Subject and Details are optional for this type; subject defaults to Order Form — <deal name>. • Quote resolution is best-effort and never blocks the filing. deriveRoute needs no change — orderform isn't an Ironclad key and Order Form has no NDA/DPA/ESA prefix, so it routes sf-case. The new action id lfd_intake_modal_quote carries the lfd_intake prefix, so it reaches intakeOppOptions through the existing dispatcher.
#44
lfd: offer the opportunity picker on the request types that can take it
Joe ChiusanoLegal Front Dooropened 2026-09-10closed 2026-09-102 files, +131 / −34
Closed
Offers the opportunity picker on the request types that can take it, so filings associate the way legal's existing records do. Of 5,205 Cases on the Legal Contracting record type: • count • Account and Opportunity — 1,457 • Opportunity only — 3,569 • Neither — 179 • Account with no Opportunity — 0 Ten of the sixteen request types were anchored to account, and that mode offers only accounts — so those filings would carry an account and no opportunity, a shape the org currently has none of. They would not error; they would sit outside anything keyed on the opportunity. The three modes do not behave the way the names suggest: "account" → accounts only, no opportunity option "opportunity" → Opportunities first, Accounts as the escape hatch "either" → Accounts first, then Opportunities opportunity is already the shape wanted here — its own validation message was written for it: "Pick the opportunity (or an account, if the deal isn't listed)." A genuinely pre-deal request still files against an account. And createLegalCase resolves the account from the opportunity before insert, so these Cases carry both fields rather than trading one for the other. Moved: DPA, all four ESAs, MSA Review, AI Addendum, Outcome Study.
#43
lfd: collect the eight fields Salesforce requires on amendments
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-102 files, +146 / −0
Merged
Collects and sends the eight fields Salesforce requires on an amendment request. Winston asks which kind of amendment a request is and files the Case as that sub-type, which is correct. Salesforce additionally applies eight validation rules to any amendment type, each demanding a field the form did not previously collect: • Field — Applies to • Basis_for_Amendment_Request__c — all amendments • Impact_if_Not_Approved__c — all amendments • What_Does_Approval_Accomplish__c — all amendments • Existing_Order_Form_Link__c — all amendments • Reason_for_Amendment__c — all amendments • Requesting_Party__c — all amendments • Have_Licenses_Been_Deployed__c — Licence Swap • Swap_Rights__c — Licence Swap These are enforced at the record level rather than in the form, so the form now gathers them and the filer sends them. Reason_for_Amendment__c is a multi-select and is sent semicolon-joined; its options match the org's value set exactly. The sub-type and both Licence Swap answers are required in the modal, so the request cannot be submitted incomplete.
#42
lfd: enforce counterparty paper on MSA reviews in the request shape
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-102 files, +117 / −27
Merged
Applies the counterparty-paper rule for MSA reviews in the request shape rather than only in the form, and adds a safeguard for request types a record type does not offer. 1. Paper source. An MSA review is the customer's own master agreement, so the paper source is coerced in formShape — where every caller passes through — rather than depending on what the radio submits. The assigned lawyer sees the right paper source, and the requester is asked for the customer's document instead of being shown the our-paper checklist. 2. Questionnaires carry no paper source. They are not contract paper, so the field is set to not-applicable. Deliberately narrow: the "Other" group holds real contract documents, so it is untouched. 3. A request type a record type does not offer no longer stops the filing. A picklist value can be active on the field and still be unavailable to the record type, which governs the insert. Where that happens the Case files as Other, and the real request type is written into the description with the reason — so it degrades legibly rather than silently. The guard requires the error to name Agreement_Type__c, so a rejection on another field is never re-filed under a different type.
#41
lfd: requester view ordering, nudge stamping, and intake retention
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-105 files, +89 / −5
Merged
Three changes, and I would suggest taking them as two. Ready as-is: • My Requests ordering. The query now orders on a field every document carries. Firestore silently omits documents missing the orderBy field, so requests written before that field existed were absent from the requester's own view rather than merely out of order. • Nudge stamping. The nudge timestamp is written after delivery rather than before, so a failed send does not consume the window that would have allowed a retry. Needs a decision from you: • Intake retention. A daily job removes lfdIntakeInbound documents older than fourteen days, 300 per run. Those records hold signer names, email addresses and counterparty details as submitted, and nothing currently removes them, so the store grows indefinitely. Fourteen days is my placeholder, not a policy — and as written the sweep is by age alone, so it would also remove anything that never completed processing. Suggested split: take the two fixes now; hold retention until you have set a period and decided whether unprocessed records should be exempt or surfaced first. I am happy to re-cut this as two PRs if that is easier.
#40
lfd: tighten three authorisation and environment checks
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-103 files, +57 / −2
Merged
Tightens three checks so each one tests the thing it is actually protecting. • Assignment verifies both the actor and the proposed assignee against the triage roster, rather than accepting a role claim on its own. • Rate limiting on lfdRate no longer rests on the email domain alone. • The Case mirror gains the sandbox guard sf-demo.ts already applies — it refuses any org that does not answer IsSandbox, so the job cannot read production even with valid credentials. The mirror guard is the same IsSandbox gate used elsewhere in this codebase rather than a second mechanism, so there is one behaviour to reason about and one place to change it if the sandbox-only constraint is ever lifted deliberately.
#39
lfd: align four requester messages with what the system did
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-103 files, +98 / −16
Merged
Brings four requester-facing messages into line with what the system actually did, and closes the loop when a ticket stops being self-serve. • A filing that is dropped now tells the requester so, rather than ending quietly. • The notice view states the outcome that was reached rather than the one that was intended. • Clearing selfServe re-triggers the Salesforce mirror. It previously gated on an owner change, so a beta request that returned to the queue could sit there with no owner ever notified. • Closing a ticket in triage now closes the Salesforce Case too, rather than leaving Firestore and Salesforce disagreeing.
#38
lfd: raise an all-error poller run, and rotate both queues
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-104 files, +135 / −1
Merged
Two things, plus the database indexes both pollers need. 1. An all-error run becomes audible. A structural condition — a missing field permission, a revoked scope, an expired secret — fails identically for every row in a run, and that pattern was logged rather than raised. New functions/src/poller-health.ts alerts the triage channel when checked > 0 && errors === checked, with a six-hour cooldown so a persistent condition reports once rather than continuously. These two pollers are the only correction for a receipt that has already promised a requester something, so a quiet one is worth hearing about. 2. Both queues rotate. Each pending query is ordered by createdAtMs, so the window advances instead of re-reading the same rows. 3. firestore.indexes.json gains the two composite indexes these ordered queries require: ndaOutcome+createdAtMs and betaOutcome+createdAtMs.
#37
lfd: hold the unsigned label until the writeback can be seen
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-102 files, +62 / −5
Merged
Holds back the "still unsigned" chase when the system has no way to see a signature at all. The beta poller judged a document unsigned from the absence of an execution stamp. That stamp arrives via DocuSign's status write-back, so where the write-back is not reaching an environment, every agreement looks unsigned regardless of what the counterparty has actually done — and the requester is chased for something they may already have completed. queryWritebackAgeDays() establishes how recently any status has landed. Where nothing has arrived within the window, the poller says it cannot currently observe execution instead of asserting the agreement is outstanding. The sandbox's DocuSign Connect configuration targets production, so status never returns to the sandbox. Without this, every beta agreement filed there reads as unsigned indefinitely — including ones that have been signed.
#36
lfd: match an existing contact in the signer picker
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-102 files, +129 / −20
Merged
The Beta Terms signer picker searches contacts on the account the requester chose, reliably, and never anywhere else. The bug On 2026-09-09 the picker said a contact didn't exist when it did. Cause: the account is chosen on page 2, the same view as the signer picker, and Slack does not reliably include that page-2 selection in the view.state it sends with a block_suggestion. There was a second hole underneath: when the requester picked an opportunity rather than an account, there was no account id to scope by at all. Option 1, as reviewed — adapted to where the account lives "Stash the account id in private_metadata" — but at the page-1 → page-2 update the account isn't known yet (page 1 is type + paper). So the stash happens the moment the account/opp is picked: • The page-2 account/opportunity input gets dispatch_action (Beta Terms only), so the pick fires a block_actions event. • handleIntakeAction handles MODAL_OPP_ACTION: an opportunity pick is resolved to its AccountId; then the modal is views.update'd in place with accountId / accountName / oppId on private_metadata. Same block ids ⇒ Slack preserves everything already typed. The view hash is passed, so a stale update is a no-op rather than a clobber. • The signer options handler reads the account from private_metadata first, the live view.state second, and returns {options: []} if neither has it. Never widen. The previous commit's cross-account fallback is removed — a contact from another account is never offered as a signer. Page2Meta also carries ndaLane so page 2 can be rebuilt from its own metadata; IntakeActionPayload declares view; inputBlock takes a dispatchAction option. Merged with main (6165eea).
#35
lfd: three small fixes to what the form tells the requester
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-103 files, +90 / −11
Merged
Three small fixes to what the request form tells the person using it. 1. The Beta Terms route chip. "Beta Terms" is removed from IRONCLAD_VALUE_PREFIXES. That set is evaluated after the category keys, so while the value remained there deriveRoute("Beta Terms", "beta") still resolved to ironclad-workflow and the wrong lane chip showed on screen. 2. One test of whether the NDA lane exists. A single ndaLaneReady() predicate replaces two checks that could disagree — one asked whether a dependency object existed, the other whether a secret was set, and intakeDeps() always built the object. intakeDeps() no longer fabricates the Ironclad object from empty secrets, so the poller skips with a log line when unconfigured rather than presenting a lane that cannot run. 3. Three modal exits now land an outcome. Three early returns left the dialog on "Filing…" indefinitely, two of them without saying anything. Each now resolves to a stated outcome.
#34
lfd: environment interlock on outbound signature requests
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-102 files, +99 / −1
Merged
Adds an environment interlock so a signature request can only reach an external counterparty from production. signerAllowed() runs in ironclad-launch.ts before the token request, so a non-production deployment does not reach the vendor at all. Internal addresses stay permitted everywhere, which keeps staging and local work fully exercisable. The gate decides for itself which environment it is in, by reading the project id the platform sets (GCLOUD_PROJECT, GOOGLE_CLOUD_PROJECT, or FIREBASE_CONFIG). Production therefore turns itself on and every other runtime stays off with nothing to remember at deploy time — the failure being guarded is an unrecallable email to a customer, so it should not depend on someone setting a variable correctly. IRONCLAD_LIVE_SEND remains available as an explicit override in either direction, and IRONCLAD_INTERNAL_DOMAINS defaults to betterup.co. Where the project id cannot be resolved the gate closes and logs loudly, so the safe direction is the default.
#33
lfd: claim the intake work before filing it
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-103 files, +142 / −0
Merged
Claims the inbound intake document before any of the filing work begins, so a repeated delivery cannot repeat the work. lfdIntakeProcess creates a Contact, a Salesforce Case and, on the self-serve lane, a real Ironclad workflow. Firestore triggers deliver at least once, so the work needs a claim ahead of it for a redelivery to be recognised as one — otherwise all three steps can repeat, including a second envelope to a counterparty. The processed field already on the document becomes that claim. New functions/src/claim.ts exposes claimOnce(), a small transaction extracted so it is testable and reusable. It returns a tri-state — claimed, duplicate, error — and fails closed: a Firestore error is never read as permission to proceed. A refused claim is reported to the requester rather than absorbed. The transaction is the one already proven in this repo at lfdProcess.ts:45-55; this lifts it out rather than inventing a second pattern. Tri-state matters — a boolean would have to choose between treating an infrastructure error as a duplicate (silently dropping a real filing) or as a claim (defeating the point).
#32
lfd: confirm NDA execution from Ironclad's sign-status
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-102 files, +232 / −57
Merged
Makes the signed-NDA lane confirm execution from something Ironclad states, rather than infer it from the name of a returned file. 1. Execution is read, not matched. Completion now comes from GET /workflows/{id}/sign-status, whose top-level status is a documented closed enum; only complete counts. Filenames are demoted to choosing which file to attach once execution is already established, and packet- and draft-class documents are refused outright, however alone they are on the workflow. 2. Every Ironclad read carries x-as-user-email. The API requires it on reads as well as writes. The launch path sent it; the read paths did not, so signed-copy lookups came back 403. Adding it to all three reads restores the completion signal. 3. An exhausted retry is its own state. Where the document cannot be retrieved, the ticket lands on a terminal complete-unverified that says only that Ironclad reports the workflow finished, and posts to the triage thread so a person owns it. 4. Cancellation undoes the self-serve close. Launch closes the Winston ticket, closes the Salesforce Case and stamps selfServe. The cancel branch now clears selfServe, reopens the ticket and reopens the Case, so the state matches what the requester is told. 5. The queue rotates. The pending query is ordered by createdAtMs and rows that can never be polled are retired, so the window moves rather than re-reading the same set. All requester-facing copy in this file goes through one function keyed on what was actually observed, so a new state cannot acquire new words without first acquiring a field. reopenLegalCase uses Status: "New" — verified against the org as open (IsClosed=false), the record type's default, and carrying 127 live legal Cases.
#31
lfd: beta terms — read the real send outcome back, not the stamp
Joe ChiusanoLegal Front Dooropened 2026-09-09merged 2026-09-096 files, +401 / −1
Merged
30 was the NDA half of self-serve follow-through. This is the beta-terms half. Beta terms never touch Ironclad. Winston marks them sf-beta-terms, sets Agreement_Type__c = 'Beta Terms' on the Case, and BU18_Beta_Terms_Auto_Send (v3, active in BU_Full) sends the presigned document through the "Beta Terms (Case)" DocuSign config. The receipt tells the requester this happens automatically, and nothing has ever checked that it did. The two facts this is built on 1. Beta_Terms_Sent_At__c is not proof of a send. The flow stamps it when it runs, not when the envelope leaves. Of the three beta-terms Cases that have ever existed in prod, 00024799 (21 Aug) is stamped sent at 14:07:55 while dfsle logged "Envelope Sending failed — The Envelope is not Complete" at 14:08:04. Nine seconds. Nobody found out, and it still reads as sent everywhere today. 2. There is no success row to look for. All 1,082 dfsle__Log__c rows in prod are Severity = ERROR. A send that works writes nothing at all. So from Salesforce a send can only ever be disproved, never confirmed — and that asymmetry is the whole shape of the classifier. What it does lfdBetaTermsPoll, every 15 min, over tickets with betaOutcome == "pending": • state — evidence — action • failed — an ERROR row with dfsle__SourceId__c = the Case id — tell the requester, clear selfServe so it goes back in the queue, flag triage • signed — Beta_Terms_Executed_At__c (stamped by BU18BetaTermsFileSweep) — tell the requester it's executed • nostamp — filed as Beta Terms, no stamp 30 min later — the flow never fired — same as failed: back in the queue • stalled — sent, unexecuted past 7 days (client p90 signing is 5) — one triage nudge, never a second Nothing here claims a send succeeded. "Sent, not yet signed" is the honest ceiling. Verif…
#30
lfd: the signature packet is not the executed NDA
Joe ChiusanoLegal Front Dooropened 2026-09-09merged 2026-09-091 file, +13 / −3
Merged
One-line fix to the ranking in nda-signed.ts, found while looking at where a Winston-filed NDA is stored. The defect documentAttributes() scores documents with /sign|execut|final|fully/ and puts anything matching in the top tier. The live Mutual NDA workflow carries sentSignaturePacket — the paper that went out for signature — alongside signed, the executed copy. sign matches the packet, so both land in tier 0, and lfdNdaSignedPoll takes documentAttributes(full)[0]. Which document gets attached therefore comes down to Object.entries() order on the workflow's attributes. Measured 2026-09-08 on workflow 6aa06a60eb17baed09fd61c8 (case 00024678): sentSignaturePacket is the only document-bearing attribute there, and it scores 0. On case 00024673, which does have a signed copy, the correct document is picked today — but only because signed happens to enumerate first. Where the packet enumerates first, the poller attaches the unsigned packet to the Case and posts "✍️ Your NDA is fully signed" to the requester. The fix Test the packet pattern first and give it its own tier between executed and draft, so the executed copy wins regardless of enumeration order. Verified against the attribute names in play (signed, signedCopy, executedCopy, countersigned, fullyExecuted, sentSignaturePacket, signaturePacket, draft) — the executed copy ranks first in every case, and a packet-first ordering still resolves to signed. No behaviour change on a workflow whose only document is the executed copy.
#29
lfd: file the NDA onto its Salesforce Case
Joe ChiusanoLegal Front Dooropened 2026-09-08closed 2026-09-098 files, +807 / −0
Closed
The NDA a rep sends through the fast lane lives in Ironclad. The Case gets a comment with the workflow link and nothing else — case 00024678 in BU_Full has zero ContentDocumentLink rows, so Salesforce shows a legal request with no paper on it. This files the document on the Case: the draft (or the packet that went out for signature) as soon as Ironclad has one, then the executed copy as a new version of the same file once signing completes. One NDA per Case, version history reading draft → executed, rather than a pile of near-identical PDFs. Idempotent on Ironclad's version id — a pass with nothing new does no download and no write.
#28
lfd: AI-first questions, single-pick types, account/opp anchor, file upload, lifecycle visibility, queue redesign
Nick RaffeyLegal Front Dooropened 2026-09-04merged 2026-09-0820 files, +3007 / −875
Merged
The 2026-09-04 build round for the Sept 14 pilot — four parallel streams plus an adversarial review pass, integrated and verified. What's in here AI-first legal questions (new lfd-question.ts): the Legal questions door now runs three tiers — answer in-thread when the FAQ/policy corpus supports it at high confidence (with citations and an "Ask a human instead" escape hatch), auto-assign to the roster-matched lawyer when the routing matrix names one, manual triage otherwise. Every failure path degrades to the pre-existing manual behavior. LEGAL_QUESTIONS_ENABLED on; lfdIntakeProcess to 1GiB (knowledge PDFs OOM 512MiB). Intake form: request type reverted to single-pick (multi-request → v2, per 09-04 call, stale multi modals still accepted); type-aware Account/Opportunity anchor — NDAs/Beta/DPA/ESA/MSA/AI-Addendum/Outcome search accounts, Amendments/SOWs/Questionnaires search deals (with account fallback), AccountId always stamped on the Case; optional 3-file upload block wired to the existing ContentVersion attach. Lifecycle visibility: acknowledgedAtMs/completedAtMs stamps (first-write-wins; reopen preserves history) across all four assignment/close paths, incl. the SF mirror. Queue redesign: IntakeQueue is now a card grid + filter bar + detail modal (TicketCard/TicketFilters/TicketModal/ticketChips/ticketMeta), lifecycle strip + response-time chip, SF links origin-safe for sandbox records, Mine chip (email-first roster resolution), Q&A feed tab on and gated to triagers. Review fixes (Fable pass over the full diff): escape hatch can never go silent (doc-before-post + fallback DM), transactional escalation claim (double-click ⇒ one ticket), Q&A tab audience, feed grouping for root-DM questions, never-throws contract honored, malformed docs degrade.
#27
lfd: tell the requester when a case reaches a status they'd act on
Nick RaffeyLegal Front Dooropened 2026-09-04merged 2026-09-041 file, +52 / −0
Merged
Requester-facing progress notes for a curated set of Case statuses — Drafted, Redlines (customer), Out for signature — posted into the request's Slack thread when the SF mirror sees the status change. Deliberately NOT the whole picklist: only steps that change what the requester would do next. Closing values are excluded (the close branch owns those), Assigned is excluded (the owner-change branch already announces it), and the sfMirrorSilent backfill guard is respected so the first mirror pass over historical tickets stays quiet. Authored by Joe on joe/lfd-status-progress; opened by Nick to land it with today's functions deploy alongside the already-merged #26.
#26
lfd: stamp the requester's Slack id on Winston-filed requests
Joe ChiusanoLegal Front Dooropened 2026-08-27merged 2026-08-271 file, +23 / −1
Merged
Found while checking whether #25 covered your "it has history of requests" point. It does — My requests already exists and does exactly that. But it doesn't work for anything filed in Winston, and that's live today rather than a Sept 8 risk. The bug sfDemoProxy records requesterEmail and nothing else, so requesterSlackId lands null on every Winston-filed request. Four requester-facing things key on that field: • Where — What breaks • lfd-intake.ts:1426 — My requests queries where("requesterSlackId","==",userId) — Winston-filed requests never appear • lfd-triage.ts:724 — the nudge button's ownership check refuses • lfd-triage.ts:189 — the requester mention on the triage card renders empty • lfd-digest.ts:187 — same in the digest Symptom for a user: file a request in Winston, ask the bot about your requests, get told "No requests on file yet." For legal: a triage card with no one to @. The fix sfDemoProxy already has the requester's verified email from their Firebase token, and the app already resolves Slack ids from emails (lookupSlackUserId → users.lookupByEmail, used by the Deal Calculator path). One lookup at filing time. requesterName comes along from the same token. Best-effort: lookupByEmail returns null on any failure, we log and file anyway — this can never stop a Case being created.
#25
lfd: one session per assistant thread, and name the thread
Joe ChiusanoLegal Front Dooropened 2026-08-271 file, +78 / −9
Open
Nick — you mentioned Sandstone's "create new chat" on Tuesday and said you'd love it but had no idea how. Turns out you already have it. This is the part that was stopping it working. What was wrong Slack's Agents surface gives the LFD the whole feature for free: a New chat button, a history list, and a separate thread per conversation. The app is already in agent mode — it handles assistant_thread_started, sets prompt chips and the thinking shimmer. But sessions were stored as lfdIntakeSessions/{userId} — one document per person, regardless of thread. So every chat in that list shared one state: • answering in one conversation moved another one's step • starting a second request mid-flow hijacked the first • a greeting in a fresh thread read as "start over", because nothing could distinguish a different conversation from the same one, restarted That's the "everything ends up on one thread" confusion you described. It reads like a UX problem and it's a keying bug. What this does Sessions are now per thread — ${userId}:${channelId}:${threadTs}. Several requests can be open at once and they stay independent. Threads get titled via assistant.threads.setTitle, sharpening as the request takes shape: request type first, subject once there is one. Without it every history entry looks the same, so the sidebar exists but carries no information. Compatibility • Plain non-agent DMs have no thread_ts and keep the old bare-userId id. Nothing changes for them, and any session in flight when this ships is untouched. • The one place that can't know its thread — a button on the DM root, where container.thread_ts is empty — deliberately looks up the bare per-user session, the only one that can own it. • No schema change, no migration. Old per-user documents age out on the exis…
#24
lfd: allowlist for sfDemoProxy so the Case mirror can be tested
Joe ChiusanoLegal Front Dooropened 2026-08-27merged 2026-08-271 file, +16 / −1
Merged
sfDemoProxy gated on a single hard-coded operator email. This replaces that with an explicit SF_DEMO_OPERATORS set containing you and me, compared lower-cased (Firebase doesn't normalise the email claim). One file, 16 lines, no behaviour change for you. sfDemoProxy is the only path that files a Case from Winston, so it's the only way a legalIntakeRequests doc gets a caseId. The Case-state mirror in #23 keys on exactly that field — which means that with the gate as it stands, I can't produce a single document for it to match, and the job can't be exercised before you merge it. I checked the alternatives first and they're all closed: • legalIntakeRequests is empty in staging, and it's on the deny list in scripts/seed-staging/collections.ts ("Front Door submissions: requester ids + request free text"), so seeding will never populate it. • Prod Firestore returns PERMISSION_DENIED for my account, which also rules out seed-staging — it needs roles/datastore.viewer on prod. So this is the narrowest change that makes the mirror testable.
#23
lfd: Case-state mirror job — SF Case owner/status onto legalIntakeRequests
Joe ChiusanoLegal Front Dooropened 2026-08-26merged 2026-08-277 files, +3311 / −0
Merged
The producer half of docs/lfd-sf-mirror-handover.md. Reads Case state from Salesforce every 30 minutes and writes sfOwnerId / sfOwnerName / sfCaseStatus / sfFirstResponseAt / sfSyncedAt onto the matching legalIntakeRequests doc, keyed on caseId. Additive only — one new jobs/ package plus its workflow. No existing file is touched. Winston's consumer side (lfdSfMirror → handleSfMirrorChange) has been live since 8/21 with nothing writing to it. I checked every ref on the remote: no producer existed on any branch. So the answer to "is it running with nothing to write" is no — nothing writes those fields at all, which is why prod Firestore shows no sf* fields.
#22
lfd: surface account-level GenAI opt-in on the matter compliance card
Joe ChiusanoLegal Front Dooropened 2026-08-26merged 2026-09-046 files, +28 / −2
Merged
Surfaces the account-level GenAI Opt-In and Additional AI Notes on the matter compliance card, alongside the four flags already there (ESA, DPA, AI Addendum, AI Gov). Complements 8e3f1a7 on the Deal Desk surface — same two Account fields, matter side rather than deal side. No overlapping files; merges clean onto current main. The four existing flags arrive as Opportunity text formulas mirroring Account picklists, so they need no FLS on Account itself. GenAI_Opt_In__c and Additional_AI_Notes__c have no such mirror — they exist on Account only. This extends the traversal the Case query already does (Opportunity__r.Account.*) and renders the notes as a text block, since that's where the Yes - Conditional (See Additional Terms) condition actually lives.
#21
Wiz: Upgrade @anthropic-ai/sdk to 0.91.1 (resolves 3 findings)
Wiz (bot)Dependenciesopened 2026-07-226 files, +15 / −15
Open
<a><picture><source media="(prefers-color-scheme: dark)" srcset="https://assets.wiz.io/wiz-code/banners/pull_request_banner_dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://assets.wiz.io/wiz-code/banners/pull_request_banner_light.svg"><img align="top" valign="top" alt="Wiz Remediation Pull Request Banner" title="Wiz Remediation Pull Request Banner" src="https://assets.wiz.io/wiz-code/banners/pull_request_banner_light.svg"></picture></a> Wiz has created this PR to fix 3 findings detected in this project Changes were made to the following file(s): • functions/package-lock.json • functions/package.json • jobs/priority-sync/package-lock.json • jobs/priority-sync/package.json • jobs/type-audit/package-lock.json • jobs/type-audit/package.json Vulnerabilities: • Component — Findings — Locations • @anthropic-ai/sdk<br>0.82.0 → 0.91.1 — <a><picture><source media="(prefers-color-scheme: dark)" srcset="https://assets.wiz.io/wiz-code/short_severity_tags/medium_dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://assets.wiz.io/wiz-code/short_severity_tags/medium_light.svg"><img align="top" valign="top" alt="Medium" title="Medium" src="https://assets.wiz.io/wiz-code/short_severity_tags/medium_light.svg"></picture></a> CVE-2026-41686 — /functions/package.json<br>/jobs/priority-sync/package.json<br>/jobs/type-audit/package.json To detect these findings earlier in the dev lifecycle, try using <a href="https://marketplace.visualstudio.com/items?itemName=WizCloud.wiz-vscode" target="_blank">Wiz Code VS Code Extension.</a>
#20
Wiz: Upgrade @anthropic-ai/sdk to 0.91.1 (resolves 3 findings)
Wiz (bot)Dependenciesopened 2026-06-23closed 2026-07-226 files, +15 / −15
Closed
<a><picture><source media="(prefers-color-scheme: dark)" srcset="https://assets.wiz.io/wiz-code/banners/pull_request_banner_dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://assets.wiz.io/wiz-code/banners/pull_request_banner_light.svg"><img align="top" valign="top" alt="Wiz Remediation Pull Request Banner" title="Wiz Remediation Pull Request Banner" src="https://assets.wiz.io/wiz-code/banners/pull_request_banner_light.svg"></picture></a> Wiz has created this PR to fix 3 findings detected in this project Changes were made to the following file(s): • functions/package-lock.json • functions/package.json • jobs/priority-sync/package-lock.json • jobs/priority-sync/package.json • jobs/type-audit/package-lock.json • jobs/type-audit/package.json Vulnerabilities: • Component — Findings — Locations • @anthropic-ai/sdk<br>0.82.0 → 0.91.1 — <a><picture><source media="(prefers-color-scheme: dark)" srcset="https://assets.wiz.io/wiz-code/short_severity_tags/medium_dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://assets.wiz.io/wiz-code/short_severity_tags/medium_light.svg"><img align="top" valign="top" alt="Medium" title="Medium" src="https://assets.wiz.io/wiz-code/short_severity_tags/medium_light.svg"></picture></a> CVE-2026-41686 — /functions/package.json<br>/jobs/priority-sync/package.json<br>/jobs/type-audit/package.json To detect these findings earlier in the dev lifecycle, try using <a href="https://marketplace.visualstudio.com/items?itemName=WizCloud.wiz-vscode" target="_blank">Wiz Code VS Code Extension.</a>
#19
Real-time Slack alerts on legal status-field changes
Nick RaffeyLegal Front Dooropened 2026-06-10merged 2026-06-103 files, +227 / −4
Merged
When the legal team changes a tracked status field in Salesforce, the next deal-desk-sync run DMs the legal team via the Legal slackbot, showing the previous and new value plus account, opportunity, who edited it, and a dashboard link. Hooks into the change-detection deal-desk-sync already does (the same logic that archives previous status values), so there's no new polling or duplicate diff.
#18
Wiz: Upgrade @anthropic-ai/sdk to 0.91.1 (resolves 3 findings)
Wiz (bot)Dependenciesopened 2026-05-14closed 2026-06-236 files, +15 / −15
Closed
<a><picture><source media="(prefers-color-scheme: dark)" srcset="https://assets.wiz.io/wiz-code/banners/pull_request_banner_dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://assets.wiz.io/wiz-code/banners/pull_request_banner_light.svg"><img align="top" valign="top" alt="Wiz Remediation Pull Request Banner" title="Wiz Remediation Pull Request Banner" src="https://assets.wiz.io/wiz-code/banners/pull_request_banner_light.svg"></picture></a> Wiz has created this PR to fix 3 findings detected in this project Changes were made to the following file(s): • functions/package-lock.json • functions/package.json • jobs/priority-sync/package-lock.json • jobs/priority-sync/package.json • jobs/type-audit/package-lock.json • jobs/type-audit/package.json Vulnerabilities: • Component — Findings — Locations • @anthropic-ai/sdk<br>0.82.0 → 0.91.1 — <a><picture><source media="(prefers-color-scheme: dark)" srcset="https://assets.wiz.io/wiz-code/short_severity_tags/medium_dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://assets.wiz.io/wiz-code/short_severity_tags/medium_light.svg"><img align="top" valign="top" alt="Medium" title="Medium" src="https://assets.wiz.io/wiz-code/short_severity_tags/medium_light.svg"></picture></a> CVE-2026-41686 — /functions/package.json<br>/jobs/priority-sync/package.json<br>/jobs/type-audit/package.json To detect these findings earlier in the dev lifecycle, try using <a href="https://marketplace.visualstudio.com/items?itemName=WizCloud.wiz-vscode" target="_blank">Wiz Code VS Code Extension.</a>
#17
fix(zip-sync): incremental sync — drop run time from ~15min to ~1min
Nick RaffeySync jobsopened 2026-05-13merged 2026-05-133 files, +197 / −17
Merged
Summary • Probed Zip's API and confirmed it does not support any updated_after / request_updated_after filter (returns 400 INVALID_PARAM). Drove the design choice. • Switches zip-sync from a 2-year full re-scan every run to a watermark-based incremental approach with two passes: • Pass 1: created_after = lastSuccessfulRunAt − 1h. Catches new requests. • Pass 2: re-fetch every Firestore doc where isClosed=false by ID via /requests/{id}. Catches status transitions on existing open requests that Pass 1 can't see (since there's no update filter on /requests or /approvals). • Watermark lives in meta/zipRequests.lastRunAt and only advances on a successful run, so a failed run won't create a gap. • --backfill flag retained for manual full sweeps; first-ever runs auto-backfill if no watermark is present. • New npm run probe:incremental script + src/probeIncremental.ts documents the probe methodology so future devs can verify Zip API capabilities without guessing. Why Every 30-min cron run was taking ~15 minutes scanning thousands of approvals. After 44h of skipped runs (separate bug, already fixed in #16), the first recovery run took 25+ minutes. Cron at 10-15 min was infeasible. With this change, dry-run shows a typical run processes ~4 approvals + ~150 open-request refreshes, total ~1 min.
#16
fix(sync): drop legal-sync-notes step to unblock Zip + Slack
Nick RaffeyLegal Front Dooropened 2026-05-13merged 2026-05-131 file, +4 / −13
Merged
Summary • The legal-sync-notes step in .github/workflows/sync.yml has been failing on every scheduled run for ~44h (missing GOOGLE_DRIVE_SA_KEY secret). • Because all syncs share one job and run sequentially, the failure cascaded to skip zip-sync and slack-legal-sync, which both run after it — that's why the Operator Console shows Zip + Slack as 44h stale while Salesforce + Ironclad (which run earlier) stayed fresh. • We don't want Drive sync running right now anyway. Removing the step until it's actually wired up. Source under jobs/legal-sync-notes/ is retained for later.
#15
Wiz: Upgrade @anthropic-ai/sdk to 0.91.1 (resolves 3 findings)
Wiz (bot)Dependenciesopened 2026-05-12closed 2026-05-146 files, +15 / −15
Closed
<a><picture><source media="(prefers-color-scheme: dark)" srcset="https://assets.wiz.io/wiz-code/banners/pull_request_banner_dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://assets.wiz.io/wiz-code/banners/pull_request_banner_light.svg"><img align="top" valign="top" alt="Wiz Remediation Pull Request Banner" title="Wiz Remediation Pull Request Banner" src="https://assets.wiz.io/wiz-code/banners/pull_request_banner_light.svg"></picture></a> Wiz has created this PR to fix 3 findings detected in this project Changes were made to the following file(s): • functions/package-lock.json • functions/package.json • jobs/priority-sync/package-lock.json • jobs/priority-sync/package.json • jobs/type-audit/package-lock.json • jobs/type-audit/package.json Vulnerabilities: • Component — Findings — Locations • @anthropic-ai/sdk<br>0.82.0 → 0.91.1 — <a><picture><source media="(prefers-color-scheme: dark)" srcset="https://assets.wiz.io/wiz-code/short_severity_tags/medium_dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://assets.wiz.io/wiz-code/short_severity_tags/medium_light.svg"><img align="top" valign="top" alt="Medium" title="Medium" src="https://assets.wiz.io/wiz-code/short_severity_tags/medium_light.svg"></picture></a> CVE-2026-41686 — /functions/package.json<br>/jobs/priority-sync/package.json<br>/jobs/type-audit/package.json To detect these findings earlier in the dev lifecycle, try using <a href="https://marketplace.visualstudio.com/items?itemName=WizCloud.wiz-vscode" target="_blank">Wiz Code VS Code Extension.</a>
#14
Wiz: Upgrade @anthropic-ai/sdk to 0.91.1 (resolves 3 findings)
Wiz (bot)Dependenciesopened 2026-05-09closed 2026-05-126 files, +15 / −15
Closed
<a><picture><source media="(prefers-color-scheme: dark)" srcset="https://assets.wiz.io/wiz-code/banners/pull_request_banner_dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://assets.wiz.io/wiz-code/banners/pull_request_banner_light.svg"><img align="top" valign="top" alt="Wiz Remediation Pull Request Banner" title="Wiz Remediation Pull Request Banner" src="https://assets.wiz.io/wiz-code/banners/pull_request_banner_light.svg"></picture></a> Wiz has created this PR to fix 3 findings detected in this project Changes were made to the following file(s): • functions/package-lock.json • functions/package.json • jobs/priority-sync/package-lock.json • jobs/priority-sync/package.json • jobs/type-audit/package-lock.json • jobs/type-audit/package.json Vulnerabilities: • Component — Findings — Locations • @anthropic-ai/sdk<br>0.82.0 → 0.91.1 — <a><picture><source media="(prefers-color-scheme: dark)" srcset="https://assets.wiz.io/wiz-code/short_severity_tags/medium_dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://assets.wiz.io/wiz-code/short_severity_tags/medium_light.svg"><img align="top" valign="top" alt="Medium" title="Medium" src="https://assets.wiz.io/wiz-code/short_severity_tags/medium_light.svg"></picture></a> CVE-2026-41686 — /functions/package.json<br>/jobs/priority-sync/package.json<br>/jobs/type-audit/package.json To detect these findings earlier in the dev lifecycle, try using <a href="https://marketplace.visualstudio.com/items?itemName=WizCloud.wiz-vscode" target="_blank">Wiz Code VS Code Extension.</a>
#12
fix(rules): allow Legal Brain allowlist to read legalQuestions
Nick RaffeyLegal Front Dooropened 2026-05-05merged 2026-05-111 file, +22 / −1
Merged
Summary • Frontend gate (src/lib/access.ts) already permits Sarah Binder, but Firestore rules still gated legalQuestions on isAdmin() (Nick only), causing "missing or insufficient permissions" when Sarah loaded Legal Brain. • Adds canAccessLegalBrain() rule helper mirroring the frontend allowlist and applies it to legalQuestions. • Rules already deployed to production via firebase deploy --only firestore:rules; this PR just lands the source change.
#11
feat(needs-attention): user-scoped + dismiss/undo in deal desk
Nick RaffeyDeal Deskopened 2026-05-01merged 2026-05-019 files, +260 / −54
Merged
Summary • Needs Attention (Matters + Deal Desk) now filters to rows associated with the logged-in user. Sarah Binder and Nick/Nicholas Raffey always see everything. • Cloned PriorityFeed dismiss + undo + show-dismissed toggle into the Deal Desk Needs Attention widget. Per-user state persisted to Firestore (\userDealDeskDismissals\). • Fixed scrollbar overlapping ACV values in the Deal Desk Needs Attention list.
#10
fix(deal-desk-sync): unwrap SF currency cells in asNumber
Nick RaffeyDeal Deskopened 2026-05-01merged 2026-05-011 file, +5 / −0
Merged
Summary Salesforce report cells for currency fields come back as \{ amount: number, currency: string }\ objects. The sync's \asNumber()\ only handled \number\ and \string\, so it returned \null\ for those — leaving \firstYearAcv\ empty in Firestore and making all the headline ACV figures on the Deal Desk dashboard show \$0. This re-applies the same fix that was previously reverted in \f349dd1\.
#9
feat(deal-desk): restore customizable widget grid + 21 widgets
Nick RaffeyDeal Deskopened 2026-05-01merged 2026-05-016 files, +1601 / −710
Merged
Summary Re-applies the previously reverted widget grid to Deal Desk so users can drag/resize, add, and reset widgets — same pattern as Matters. Keeps the popup change (PR #7) intact: clicking a row still opens the right-side modal. • "Edit widgets" toggle drives drag/resize + Add widget drawer + reset-to-default • Per-user layout persisted to \userDealDeskLayouts/{uid}\ • \useDashboardLayout\ parameterized via \LayoutScope\ so Matters and Deal Desk share the same hook without affecting Matters call sites • 21-widget catalog: 12 stat cards, 9 breakdowns, 4 lists (covers ACV, risk, forecast, region, ownership, agreement coverage, close-date buckets, etc.)
#8
feat: restore Legal Intake and Legal Brain views
Nick RaffeyLegal Front Dooropened 2026-05-01merged 2026-05-0126 files, +7210 / −18
Merged
Summary Restores the LegalIntake (\"Legal Triage\") and LegalBrain features that were built on a worktree branch but never made it onto \main\. • LegalIntakeView / LegalIntakeGraph — Slack-sourced legal Q&A graph • LegalBrainView / LegalBrainGraph — unified people / accounts / items knowledge graph (deals included via account bridge) • New routes \/legal-intake\ and \/legal-brain\ wired into \App.tsx\ and \Sidebar.tsx\ • New Cloud Function \functions/src/chat.ts\ for chat retrieval • Firestore rules update granting read access on \legalQuestions\ • New \jobs/slack-legal-sync\ job (Slack → Firestore data pipeline) • Adds \react-force-graph-2d\ dependency
#7
feat(deal-desk): replace inline expand with right-side popup
Nick RaffeyDeal Deskopened 2026-05-01merged 2026-05-012 files, +356 / −136
Merged
Summary • Clicking a deal row in the opportunities table now opens a right-side slide-over modal matching the Matters detail layout, instead of expanding inline. • New DealDeskDetail.tsx renders opportunity fields, risk note + dashboard-only notes, and compliance status; surfaces Salesforce opportunity/account links when the row carries SF IDs. • DealDeskView.tsx drops the per-row expanded state and inline NotesGrid; row component now takes an onClick prop and the parent owns selected state.
#6
revert: deal-desk widget grid + currency fix (PR #5)
Nick RaffeyDeal Deskopened 2026-05-01merged 2026-05-017 files, +700 / −1607
Merged
Reverts both commits from #5 so main is content-equivalent to cd9fe98 again. Needed because production was previously deployed from a local merge of relaxed-kilby (Legal Brain / Legal Intake), which was never on origin — my deploy from main lost those features. Reverting on origin/main so the next push (from another chat) lands on a clean base. The work isn't lost — it's still on commits c42aaba and b35e865 in repo history.
#5
feat(deal-desk): customizable widget grid (+ duplicate currency fix)
Nick RaffeyDeal Deskopened 2026-05-01merged 2026-05-017 files, +1607 / −700
Merged
Summary • Customizable widget grid on the Deal Desk page, mirroring the Matters dashboard pattern: Edit-mode toggle, drag/resize, Add widget drawer, reset-to-default, per-user layout persisted at userDealDeskLayouts/{uid}. Default layout matches the prior static page so existing users see no change unless they choose to customize. • Generalize useDashboardLayout to take an optional LayoutScope (collection name, default layout, stat-card ids, legacy id resolver). Existing Matters call sites unchanged. • Also includes a fix for the SF currency-cell {amount, currency} object form in jobs/deal-desk-sync/src/sync.ts that was leaving firstYearAcv null on every row. Relationship to #1 PR #1 (relaxed-kilby-ab7fda) ships the same currency-cell fix plus Legal Brain / Legal Intake / slack-legal-sync. Recommended merge order: #1 first, then this PR. After #1 lands I'll rebase — the fix(deal-desk-sync) commit will become empty and drop, and the DealDeskView.tsx conflict resolves by taking this PR's version (the numericCell patch in #1 is already folded into the new src/components/DealDesk/widgets.tsx). Widget catalog (21) • Stat cards (12): Open deals · Total 1st-year ACV · Total Renewal ACV · Median ACV · Largest deal · Closing this week / month / quarter · Confirmed gap value · High legal risk · Past close · Not assessed • Breakdowns (9, multi-chart bar / vert-bar / donut / list): Region · Forecast Cat · Legal Risk · Owner · ACV Bucket · Close Month · ESA / DPA / AI Gov status • Lists (4): Needs attention · Top by ACV · Closing soon (14d) · Agreement coverage matrix
#4
chore: bump version 0.1.0 → 0.2.0
Nick RaffeyPlatformopened 2026-05-012 files, +3 / −3
Open
Summary Footer's been showing \v 0.1.0\ since project start; bumping to mark the first real milestone (Legal Intake + Legal Brain + chat fix + dashboard polish all shipped on 0.1.0). After merge + redeploy, footer reads \v 0.2.0 · <sha>\.
#3
chore: stamp build with git short SHA so deploys are identifiable
Nick RaffeyPlatformopened 2026-05-011 file, +16 / −1
Open
Summary Every build today bakes in the same 0.1.0 string from package.json into the footer's v {__APP_VERSION__}, so deploys are visually indistinguishable — there's no way to tell which commit is running in production without diffing the bundle. This appends the git short SHA at build time (with a -dirty suffix when the working tree isn't clean), so the footer shows e.g. v 0.1.0 · bb03a3c execSync calls fall back to dev if git isn't available (e.g. CI image without .git).
#2
fix(chat): allow Cloud Run in CSP, rename env var to match code
Nick RaffeyPlatformopened 2026-05-012 files, +5 / −3
Open
Summary The chat is blocked in production for two reasons, both fixed here: 1. CSP blocks the chat proxy. Firebase Functions v2 (which chatProxy uses) serves from Cloud Run — the actual URL is chatproxy-c53nz4cppq-uc.a.run.app. The current CSP connect-src only allows .cloudfunctions.net, which is the v1 alias and isn't provisioned for v2 functions. Every chat request was being blocked at the browser. Adds https://.run.app to connect-src. 2. .env.example is stale. Still listed the legacy VITE_ANTHROPIC_API_KEY from when chat called Anthropic directly. The current code in src/lib/chat.ts reads VITE_CHAT_FUNCTION_URL. Updated to match so future deploys don't silently miss the var. After merge Rebuild and redeploy hosting (the deployed bundle was built before VITE_CHAT_FUNCTION_URL existed in .env, so it has isApiConfigured() baked in as false): npm run build firebase deploy --only hosting
#1
fix(deal-desk): unwrap SF currency cell objects + feat(legal-intake): Slack Q&A graph
Nick RaffeyLegal Front Dooropened 2026-05-0128 files, +7223 / −21
Open
This PR now contains two logical changes that landed on the same branch. Sorry for the squash — describing both for review. Part 1 — fix(deal-desk): unwrap SF currency cell objects in ACV parser The FY27 Q2 SF report returns currency cells as { amount, currency } objects instead of plain numbers; our asNumber() / numericCell() parsers only handled number / string, so firstYearAcv was being written as null for all 159 opportunities — which zeroed out every dollar figure on the Deal Desk page. Adds an object-shape branch to both helpers in jobs/deal-desk-sync/src/sync.ts and src/components/DealDesk/DealDeskView.tsx. After this lands, the next scheduled sync (lfd-sync-trigger, every 30 min) repopulates firstYearAcv with real numbers; verified end-to-end against Firestore — total ACV ~$59.88M across 159 deals, top deal $5M (WSIB). Part 2 — feat(legal-intake): Slack-sourced legal Q&A graph + chat retrieval A Nick-only "Legal Intake" route that visualizes ~3 years of legal questions asked across four Slack intake channels as an Obsidian-style force-directed graph, with categorized widgets and a filterable thread list. What ships: • jobs/slack-legal-sync/ — standalone sync job pulling top-level threads + replies from #legal-compliance-privacy, #commercial-contracting-legal, #data-use-legal-request, #legal-commercial-team. Tier-3 rate-limit-aware. Per-channel lastTs checkpoints in syncMeta/legalQuestions. • 12-category taxonomy seeded from a sample of ~95 real threads (mirrored in job + frontend). • Haiku-driven classification + entity extraction (customer account, product area, urgency, people). • Deterministic Ironclad / SF URL extraction. Never fuzzy. • Frontend route at /legal-intake, hard-gated to (email). KPI strip → graph (react-force-graph-2d) → category/channe…