Winston — pull requests

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

62total
47merged
8open
7closed, not merged
23Nick Raffey
34Joe Chiusano
5Wiz (bot)
#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-102 files, +6 / −3
Open
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…