Winston — pull requests

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

162total
134merged
15open
13closed, not merged
79Nick Raffey
78Joe Chiusano
5Wiz (bot)
#163
LFD: Board lands on the plain board; Board modal gets the Split pane's controls
Nick RaffeyLegal Front Dooropened 2026-09-299 files, +596 / −210
1. Switching to Board should land on the plain board, not pop open a ticket. 2. The Board's popup should do everything the Split view's right-hand pane does (reassign, change status, and the extra info), while keeping the popup's look.
Open

What Nick asked for

  1. Switching to Board should land on the plain board, not pop open a ticket.
  2. The Board's popup should do everything the Split view's right-hand pane does (reassign, change status, and the extra info), while keeping the popup's look.

1. Board auto-pop: root cause and fix

#157 opened the Board modal when either of two things was true: the navigation carried lfdOpen, or the URL ticket was the one the queue mounted on (meant to detect a pasted link). Both signals stay true after the moment they describe:

  • lfdOpen stays on the history entry. A bell click marks the entry with lfdOpen even while you're in Split. Switching to Board then read the marker again and opened the modal. A Board card, then Split, then Board did the same.
  • "Mounted on" isn't the same as "pasted". IntakeQueue remounts every time you come back from the Analytics / Team / Q&A tab, and on every reload, with the Split selection still in the URL. So any Split selection that survived a tab switch or a refresh popped open on Board.

Fix: a view switch now records the history entry that was showing (switchedOnKey), and the Board never opens its modal on that entry. Otherwise the modal opens only for an entry marked lfdOpen (a card or bell click, which always pushes a new entry) or for the tab's first entry, which React Router keys "default" (a genuinely pasted link). The rule lives in boardAskedToOpen in queueView.ts. Split and Map selection and deep links are untouched, because this rule only controls the Board's modal.

2. Parity checklist (Split pane → Board modal)

  • Split pane feature — Modal before — Modal after
  • Owner assign / reassign — Select plus a separate Assign button that committed immediately, no confirm — Select opens the #132 confirm-on-pick popup (shared AssignConfirmDialog); only the popup's button commits. Labelled Assign/Reassign; an owner not on the assignable list still shows by name
  • Who is assignable (questions: whole roster; Case-backed: roster members with sfUserId; never self-serve) — Duplicated copy of the rule — Same rule, one copy (useOwnerAssign)
  • Status: SF Case picklist (#162) with confirmSfStatusPick — ✓ — ✓ (unchanged)
  • Status: Winston status select for tickets with no Case (questions) — ✗ (Mark closed/Reopen only) — ✓ New / Assigned / In progress / Closed. Closing asks confirmMarkClosed
  • Close / Reopen with confirmMarkClosed (#148) — ✓ — ✓ (keeps the modal's extra self-serve guard)
  • Attorney priority — ✓ — ✓
  • Triage note + attribution — ✓ — ✓ (the draft follows the live value via the shared useTriageNoteDraft)
  • Reply composer + attachments (#141) — ✓ — ✓
  • Files (#123/#127/#134) — ✓ — ✓
  • Conversation (delivery state, via Winston, mentions) — ✓ — ✓
  • Salesforce Chatter (Case/Account/Opp) — ✓ (#158) — ✓
  • Timeline (summary waits + dated steps + "not yet assigned") — ✗ — ✓ the pane's Timeline, under the Lifecycle strip
  • SLA: target, days in, rubine when over — ✗ — ✓ in the header line
  • Time to acknowledge — ✓ (Lifecycle chip) — ✓
  • Counterparty paper note (filed into Ironclad / skipped / error) — ✗ — ✓ the pane's CounterpartyPaperNote
  • Request body rendered as Slack mrkdwn — ✗ (plain text) — ✓ SlackMrkdwn
  • Case link, Account link, Slack thread link, requester, request type, Ironclad route — ✓ — ✓
  • Self-serve notes — ✓ — ✓
  • Roster-empty hint — ✓ — ✓
  • Hidden-from-Winston, related deal — Not in the pane either — Not changed (nothing in the pane to match)

Things only the modal has (Lifecycle strip, category, paper, requester priority, nudges, triaged-by, opportunity link) stay.

Deal Desk drawer: the drawer mounts the same TicketModal, so it gets all of the above. It passes slaTargets from useLegalTriageRoster. DealTicketsSection only renders for legal/admin with the legalFrontDoor module, so there is no read-only tier to handle. The pane's readOnly requester path is unchanged.

Esc in the popup: AssignConfirmDialog listens in the capture phase and stops Escape there, so Esc closes only the confirm popup, not the whole modal underneath it. Clicking the popup's backdrop doesn't reach the modal's click-outside-to-close handler.

Test and deploy notes (Files, Verification) are on GitHub · Open on GitHub ↗
#162
LFD: Salesforce Case Status in Winston, changeable both ways
Nick RaffeyLegal Front Dooropened 2026-09-29merged 2026-09-298 files, +911 / −30
Nick: "We need the status from Salesforce to be in Winston and be able to change them bidirectionally."
Merged

What

Nick: "We need the status from Salesforce to be in Winston and be able to change them bidirectionally."

Tickets with a Salesforce Case now get a status control that lists the full Legal & Privacy Case Status picklist (Deal_Desk_Legal, all 15 values in picklist order). Picking a value writes Case.Status in Salesforce, and Winston's own status follows it. Changes made in Salesforce keep coming into Winston through the mirror, as before. Tickets without a Case keep the old New / Assigned / In progress / Closed control.

  • Split pane: the Status value-select under the case-number eyebrow shows the SF picklist for Case-backed tickets.
  • Board / Deal Desk modal: a new "Case status" select next to Priority. The modal had only Mark closed / Reopen before.
  • Catalogue (sfStatus.ts): Pending Security and Pending Infosec added as side, after Assigned. They get their own board columns and a "Now:" chip on the lifecycle strip. Not required stays closed.

Winston's internal 4-state status is not replaced. Slack cards, DMs, bells, the close sweep, analytics, filters and self-serve all still run on it.

Write path

lfdTriageAssign { requestId, sfStatus } uses the same auth as today (legal/admin). It calls setCaseStatus, which calls setRequestStatus(..., caseStatus):

  1. Validate the value against SF_CASE_STATUS_PICKLIST (400 if it isn't on the picklist). The ticket needs a Case and creds for its org (sfFor); otherwise 409. The self-serve human-close refusal runs before anything is written to Salesforce.
  2. Map the value to Winston's status:
  • New / Assigned → assigned if someone owns the ticket, otherwise new
  • Closed / Not required → closed
  • everything else → in_progress
  1. PATCH Case.Status first, strictly (setLegalCaseStatus, Sforce-Auto-Assign: false). If Salesforce refuses, the call returns 409 and Firestore is untouched.
  2. Re-read the doc, then do one doc write that sets status, sfCaseStatus, sfCaseStatusSetBy/At, and statusUpdatedBy/At (on a transition only). A pick that closes the ticket also sets sfCaseClosedByWinston; a pick that reopens it clears that stamp and clears selfServe, exactly like Reopen does today.
  3. Side effects come from the existing setRequestStatus code: requester close/reopen note, card refresh, DM button strip/restore, bell clear. A close or reopen also leaves a best-effort CaseComment after the PATCH ("Closed in Winston by X — Case set to Not required."). Moves between open statuses leave no comment.

Mark closed / Reopen also stamp sfCaseStatus now, with the value they wrote. The stamp goes in the status write, never in the early sfCaseClosedByWinston write. Otherwise the mirror would see an open ticket whose Case reads "Closed" and close it a second time.

Echo / flap safety

  • sfCaseStatus and the derived status land in the same write. When lfdSfMirror sees the new Case Status, the ticket already agrees with it. The close branch needs an open ticket and the reopen branch needs a closed one, so neither fires.
  • A progress line (Drafted / Redlines (customer) / Out for signature) is sent once. It goes out on whichever write changes sfCaseStatus first: ours or the poller's.
  • sfStatusHistory is still appended only by the trigger, once per change. The poller then reads the same value back, sees no diff, and writes nothing.
  • sfSyncedAt is not bumped, which keeps the mirror's reopen writer-check invariant.
  • Race (a poller or GitHub pass lands between the PATCH and our doc write): setRequestStatus re-reads the doc after the PATCH, and the mirror's close and reopen branches now re-check the fresh doc and stand down if Winston already applied the change. The harness covers both orderings.
  • The mirror still has no sfCreds, so it never writes back to Salesforce.

Release note

  • Legal can set any Salesforce Case Status from Winston (Split pane Status, Board / Deal Desk modal "Case status"), and it updates the Case.
  • Picking Drafted, Redlines (customer) or Out for signature posts the same progress line to the requester that a change made in Salesforce would.
  • Picking Closed or Not required asks first, then closes the request and tells the requester it's resolved. Delivered self-serve ESA/DPA tickets show the #148 "stays open for redlines" warning.
Test and deploy notes (Tests, Deploy (not done — nothing deployed or merged)) are on GitHub · Open on GitHub ↗
#161
Access requests: ask for a Winston role, approvers decide
Joe ChiusanoLegal Front Dooropened 2026-09-2910 files, +1446 / −2
From the 2026-09-28 call — "build Lumos on top of Winston." Someone asks for access, both of us get a Winston – Notify message, and one of us approves or declines.
Open

From the 2026-09-28 call — "build Lumos on top of Winston." Someone asks for access, both of us get a Winston – Notify message, and one of us approves or declines.

Worth knowing before you review

Anyone with a @betterup.co Google account can already sign in to Winston — they land as Sales with My Deals only (ensureUserDoc, the users/{userId} self-create rule, and the "they just land as sales" note on the invites card). So AEs aren't blocked from the AE view by provisioning; this flow is for anyone who needs more than Sales.

How it works

  • Step — What happens
  • Ask — Account menu → Request access (or /access-requests). They pick a role and write a business justification. Name and email come from their Google sign-in — the rules pin both to the token, so neither can be typed.
  • Notify — Each approver gets a Winston – Notify DM: who, what they're asking for, what they have today, the justification, and Review in Winston.
  • Decide — Approve / Decline on /access-requests. Approving sets users/{uid}.role — the same field the Operator → Users table edits, so resolveAccess, the rules and getUserAccess are untouched. The requester gets a Slack DM either way, and both approvers' cards flip to the outcome.

Requestable roles are the ones that exist today: Sales (AE), Sales leadership (SVP / RVP) (exec), RevOps (devops) and Legal. Admin is never requestable.

Approvers are an explicit list — you and me — kept in three places that must agree: ACCESS_APPROVER_EMAILS in src/lib/access.ts and functions/src/access-requests.ts, and canDecideAccess() in firestore.rules. It's a list rather than a role check, so a users doc write can't widen who hands out roles.

Guardrails

  • Nobody decides their own request, a request can't change an admin's role, and a settled request can't be decided again (all in one transaction).
  • Requests are create-only from the client: no updates, no deletes. Decisions go through the decideAccessRequest function only.
  • A second pending request from the same person is parked, so you only see one.
  • Requester-typed text renders as plain text in Slack, so mentions and links in it don't fire.
  • If a DM fails, the trigger retries (retry: true, capped at 5 attempts). The request is always in the in-app queue regardless.
  • Preview-as-role shows the page as that role, with filing and deciding switched off.

Codex attacked this before it went up

Two rounds. No privilege escalation found. Round 1 found 9 defects, all fixed, including:

  • a race where two requests filed together could both park;
  • Slack errors after the commit turning a successful approval into a 500;
  • a hand-picked document id that would sit in the queue undecidable;
  • Block Kit length overflow;
  • a display name that wasn't tied to the sign-in.

Round 2 found problems with DM retries and double-sends. The trigger now has a 60s timeout, and the retry lease is three times that, so a retry can never overlap a live run. Delivered DMs are appended atomically.

One finding not addressed, by design: if Slack is down at the moment of a decision, the approvers' cards aren't updated. They only link to the page, which already shows the decision.

Things for you

  1. Rules. This adds an accessRequests block to firestore.rules. Per the workflow doc, rules are yours to deploy. Staging needs them via scripts/deploy-staging-rules.sh before anyone can file there, and I haven't clicked through staging yet for that reason.
  2. Approve / Reject buttons inside the Slack DM (phase 2, not in this PR). Winston – Notify has no interactivity URL or signing secret today — only the front-door app does. If you want the buttons, turn on Interactivity for Notify and add SLACK_ALERTS_SIGNING_SECRET, and I'll wire the endpoint.
  3. RVP = "sees their reps' deals" doesn't exist yet. Sales leadership sees every deal. That's a separate build if you want it.
  4. Size. About 1,400 lines, roughly 40% of it the harness. The page, rules and functions don't work apart, so I kept it as one PR.
Test and deploy notes (Checks) are on GitHub · Open on GitHub ↗
#160
LFD: ticket thread → Salesforce Case (switchable sink, edits/deletes, backfill)
Joe ChiusanoLegal Front Dooropened 2026-09-2911 files, +1858 / −40
Every human message on a Legal Front Door ticket goes onto its Salesforce Case, with its files. That covers the requester ⟷ lawyer exchange carried by the Slack relay, plus replies written in Winston. Slack edits and deletes follow. Ships off (LFD_CHATTER_SINK defaults to off).
Open

Every human message on a Legal Front Door ticket goes onto its Salesforce Case, with its files. That covers the requester ⟷ lawyer exchange carried by the Slack relay, plus replies written in Winston. Slack edits and deletes follow. Ships off (LFD_CHATTER_SINK defaults to off).

Where it lands: a switch, pending your call

LFD_CHATTER_SINK = off | feed | casecomment, one adapter per sink behind SinkAdapter:

  • feed: one Case feed post per message. A message with files becomes a ContentPost on the first file's ContentVersion, plus FeedAttachment rows for the others (the files are already on the Case via keepTicketFile). Anyone with Case access can read it.
  • casecomment: one private CaseComment per message (IsPublished=false). Files are named in the text, since they're already in the Case's Files.

An edit or delete always goes to the record type the message was first posted as, so switching sinks later leaves nothing behind.

How it works

  • Message ids and files: both transcript writers stamp msgId (slack:<channel>:<ts> / winston:<uuid>) and caseFileIds. keepTicketFile now returns caseFileId.
  • The trigger (lfdChatterMirror, onDocumentUpdated on legalIntakeRequests): a change to the transcript prompts a reconcile of the current ticket, oldest message first.
  • Each message has one claim in lfdChatterMirror, with a lease that records its owner and a hash of the posted body.
  • So a message posts once, and is rewritten only when its text changes.
  • Out-of-order events can't undo an edit.
  • An edit that arrives mid-post marks the claim dirty; the lease holder then takes another pass.
  • An earlier message that hasn't reached the Case holds back every later one.
  • Only human messages: bot and system entries are never mirrored.
  • Wrong-org guard: tickets whose Case is in the other org are skipped (ticketInThisOrg).
  • Edits and deletes:
  • lfd.ts dmEditOf carries message_changed / message_deleted, which Slack sends as subtypes of the message.im event the app already receives. No new event or scope (im:history).
  • handleThreadEditEvent finds the message through lfdThreadMsgIndex (channel:ts, written in the same batch as the transcript entry). That index survives a reassignment.
  • applyThreadEdit updates the transcript entry, ignores stale edits, and on a delete removes the text from the ticket. The trigger then updates or deletes the Case record.
  • Echo guard: readRecordChatter leaves out the session user's own FeedItems (CreatedById from the token id or /oauth2/userinfo). If that id can't be resolved, it falls back to the · via Winston thread marker. Otherwise every message would come back into the ticket a second time as "Chatter".
  • Backfill (lfdChatterBackfill):
  • The token goes in the Authorization header.
  • GET is a dry run; POST {"write":true} writes, and is refused while the sink is off.
  • It reads tickets 50 at a time, only the fields it needs.
  • Older entries get a stable derived msgId that the trigger computes the same way, so no double posts.
  • @mentions: a renderMentions seam turns Slack ids into names using Winston's users.info lookup.

Review

Codex attacked this twice. Round 1 found 7 issues and round 2 found 4; everything fixed is in the 2nd and 3rd commits.

One finding was not taken: "CaseComment.CommentBody isn't updateable". A describe of CaseComment in BetterUp prod shows CommentBody createable and updateable. It stays on the sandbox-proof list below.

TODO before it goes live

  • [ ] Nick: visibility. Should thread messages be readable by anyone with Case access (feed), or be private CaseComments (casecomment)?
  • [ ] Sandbox proof in BU_Full, as the integration user. Check each of these:
  • FeedItem TextPost and ContentPost (RelatedRecordId = ContentVersion).
  • FeedAttachment with RecordId = ContentVersion.
  • PATCH on FeedItem Body and CaseComment CommentBody, and the delete of each.
  • The echo guard excludes our posts from the ticket's Chatter.
  • message_changed / message_deleted actually arrive for DM threads.
  • [ ] Land #125 first, so a failed or rate-limited users.info result isn't cached into mention names.
  • [ ] Deploy with the sink off. Run the backfill dry run, then flip the sink, then run the backfill with write (a person runs it).
  • [ ] Follow-up, not in this PR: an edit or delete in Slack doesn't yet change the relayed copy in the other side's Slack thread.
  • [ ] Known limits:
  • Entries written before this change have no Slack ts, so later Slack edits to them can't be followed.
  • After a message is deleted, its files stay on the Case.
Test and deploy notes (Verification) are on GitHub · Open on GitHub ↗
#159
Deal Desk: Close Month column in the CSV export
Joe ChiusanoDeal Deskopened 2026-09-281 file, +3 / −0
Replaces #122. The Close Month chip already landed on main in #156, so this carries only the CSV column (DealsTable.tsx export extras), reusing main's closeMonthFor (UTC, "Sep 2026").
Open

Replaces #122. The Close Month chip already landed on main in #156, so this carries only the CSV column (DealsTable.tsx export extras), reusing main's closeMonthFor (UTC, "Sep 2026").

  • Always exported, like Close Date / Stage / Outcome (there's no Close Month table column to de-dupe against).
  • tsc -b clean. The 3 eslint react-refresh/only-export-components errors in DealsTable.tsx are pre-existing on main (lines 102/112/516), not from this change.
#158
LFD: Salesforce Chatter in the ticket modal
Nick RaffeyLegal Front Dooropened 2026-09-28merged 2026-09-282 files, +13 / −2
The Split view pane shows Salesforce Chatter (Case / Account / Opportunity); the ticket modal used by the Board and the Deal Desk drawer never did, so Chatter looked missing there (Sasha 09-25, Nick 09-28). Mounts the pane's own \ChatterSection\ in the modal under the reply composer. Data was already synced…
Merged

The Split view pane shows Salesforce Chatter (Case / Account / Opportunity); the ticket modal used by the Board and the Deal Desk drawer never did, so Chatter looked missing there (Sasha 09-25, Nick 09-28). Mounts the pane's own \ChatterSection\ in the modal under the reply composer. Data was already synced (100/109 requests carry \sfChatter\). Hosting only.

#157
LFD: Salesforce Case statuses on the board and the lifecycle strip
Nick RaffeyLegal Front Dooropened 2026-09-28merged 2026-09-2810 files, +389 / −76
One status catalogue (src/components/LegalFrontDoor/sfStatus.ts) — the Legal Contracting Case Status picklist in picklist order. A row added there becomes a board column and (for pipeline statuses) a lifecycle step. Statuses SF sends that aren't listed yet still show as their own column + a "Now:" chip. Board: New…
Merged

What

  • One status catalogue (src/components/LegalFrontDoor/sfStatus.ts) — the Legal Contracting Case Status picklist in picklist order. A row added there becomes a board column and (for pipeline statuses) a lifecycle step. Statuses SF sends that aren't listed yet still show as their own column + a "Now:" chip.
  • Board: New / Assigned / In progress / Awaiting reply, then a column per specific SF status (On hold, Finance/DD follow-up, Drafted, Redlines ×2, Pending AE/AM review, Ready for / Out for signature). A specific SF status wins; generic New/Assigned/In Progress defers to Winston's status. Scrolls sideways.
  • Ticket modal lifecycle: Received → Acknowledged → In progress → SF pipeline → Completed ("Not required" labelled as such, per Elisa). Side statuses show as a "Now:" chip.
  • lfdSfMirror appends {status, atMs} to sfStatusHistory whenever sfCaseStatus changes, so the strip can date each step. No SF write-back; doesn't touch owner/status fields, so no bells or Slack side effects.
  • Bug: the Board no longer pops open the ticket the Split view left in the URL — only card clicks, bell clicks and pasted links open the modal.

Follow-up

Joe is adding Sasha's statuses in SF (Draft with AE / Infosec / Security / Client, Pending Security / Infosec) — one row each in sfStatus.ts once the spellings are final.

Test and deploy notes (Deploy) are on GitHub · Open on GitHub ↗
#156
Forecasting: split Commit from Most Likely, add ACV, close date, quarter + month chips
Nick RaffeyPlatformopened 2026-09-28merged 2026-09-285 files, +200 / −9
1. Commit and Most Likely are split. On Forecasting only, the Forecast Cat column and chip now show three values: Call (Commit), Most Likely, Upside (Best Case). The SF report sends the grouped "Call (Commit+ML)" label, so the deal-desk sync now also reads raw Opportunity.Forecast_Category__c into a new…
Merged

Sarah's 09-28 Loom: four tweaks to the Forecasting module.

What changes

  1. Commit and Most Likely are split. On Forecasting only, the Forecast Cat column and chip now show three values: Call (Commit), Most Likely, Upside (Best Case).
  • The SF report sends the grouped "Call (Commit+ML)" label, so the deal-desk sync now also reads raw Opportunity.Forecast_Category__c into a new forecastCategory field.
  • This runs as its own SOQL query. If it fails, the error is logged and the sync carries on; Forecasting then falls back to the grouped label.
  • Deal Desk, the forecast email, Slack alerts and the Call/Upside widgets are unchanged: they still read the grouped cell.
  1. New 1st Year ACV and Close Date columns on Forecasting. Both are sortable and included in the CSV.
  2. Quarter chip in the Forecasting filter row, the same control Deal Desk has. The top-bar scope menu stays.
  3. New Close Month chip (e.g. Sep 2026, Oct 2026), ordered by date. It's in the shared filter set, so Deal Desk and Deal Lifecycle get it too.
Test and deploy notes (Verification, Deploy) are on GitHub · Open on GitHub ↗
#155
Forecasting: add Matt Cox (view + edit)
Nick RaffeyPlatformopened 2026-09-28merged 2026-09-283 files, +3 / −1
Matt Cox moved from exec → legal on 2026-09-28 so he matches Sarah Binder's modules (Legal Brain, Front Door, Intake). That part was a Firestore data write on users/{uid} (no code).
Merged

Matt Cox moved from exec → legal on 2026-09-28 so he matches Sarah Binder's modules (Legal Brain, Front Door, Intake). That part was a Firestore data write on users/{uid} (no code).

Forecasting is allowlist-gated regardless of role, so this adds him to:

  • FORECASTING_EMAILS in src/lib/access.ts (sees the module)
  • canEditForecast() in firestore.rules (edits low/mid/high)

Also moves him from the Exec to the Legal row in docs/access-matrix.md.

Deploy: hosting (both sites) + firestore:rules.

#154
Deal Calculator: open to Nick + Joe, back in the sidebar
Nick RaffeyPlatformopened 2026-09-28merged 2026-09-283 files, +40 / −14
DEAL_CALC_EMAILS allowlist (Nick + Joe Chiusano) in src/lib/access.ts replaces the unconditional operator-only strip; applies across roles (Joe is legal). firestore.rules: dealCalculators read/write → canUseDealCalc() (same two emails; was isAdmin() = Nick only). Deal Calculator removed from NAV_HIDDEN_MODULES —…
Merged
  • DEAL_CALC_EMAILS allowlist (Nick + Joe Chiusano) in src/lib/access.ts replaces the unconditional operator-only strip; applies across roles (Joe is legal).
  • firestore.rules: dealCalculators read/write → canUseDealCalc() (same two emails; was isAdmin() = Nick only).
  • Deal Calculator removed from NAV_HIDDEN_MODULES — only allowlisted users hold the module, so nobody else sees it.
  • Admin grid lock reason now points at the allowlist.

Verified: tsc -b + eslint clean; resolveAccess → Joe gets dealCalc, a legal user with a stale dealCalc grant does not.

Deploy: firestore:rules + hosting (both sites).

#153
Legal Brain: account file shows what the team has said, and snapshots are 4x faster
Nick RaffeyLegal Front Dooropened 2026-09-24merged 2026-09-254 files, +212 / −24
PR C of the Legal Brain refresh (after #149, #150, #152).
Merged

PR C of the Legal Brain refresh (after #149, #150, #152).

What the team has said

The account legal file's third column is now Latest activity. It merges, newest first:

  • Call notes: Daily Legal Sync and meeting extracts, plus deal-call transcript terms for the transcript allowlist.
  • Winston status updates posted on the customer's deals (Commercial Legal / Deal Desk / AI Privacy; 1,150 in Firestore so far).
  • Slack discussion: AI summaries of Slack threads matched to those deals.

Chat's account_legal_file returns the same as statusUpdates and slackDiscussion. Both come from per-deal subcollections that any signed-in user can read (unchanged rules), read for the newest 40 deals.

Speed

A Brain snapshot (behind every Brain chat tool) took ~30s from a laptop:

  • Zip docs carry the full raw API payload, which was 14s on its own.
  • Deal docs carry status archives.

The snapshot now uses select() to read only the fields the join uses, and caches for 2 min per instance, so a chat turn calling several Brain tools builds it once. Cold time is now ~8s, and it'll be faster inside Cloud Functions. Results are identical (workload Amy 94, Elisa 70, Sasha 47, Sarah 45).

Not in this PR: FAQ gaps

The plan also had mining escalated Front Door questions for FAQ gaps. Held back:

  • The Front Door Q&A tab already lists every escalated question.
  • There are only 7 bot conversations, 5 of them escalated, which is nothing to find patterns in yet.

A lighter follow-up would be to pair each escalated question with the lawyer's reply on its ticket as a draft FAQ entry.

Test and deploy notes (Verified, Deploy) are on GitHub · Open on GitHub ↗
#152
Legal Brain: stop the graph reshuffling on unrelated Firestore writes
Nick RaffeyLegal Front Dooropened 2026-09-24merged 2026-09-241 file, +19 / −1
Nick saw the /legal-brain graph re-lay itself out every ~10 seconds after #150.
Merged

Nick saw the /legal-brain graph re-lay itself out every ~10 seconds after #150.

Cause: #150 added a live subscription to legalIntakeRequests. lfdCaseOwnerPoll and lfdDocReadyPoll (both every 2 min) write stamps to open tickets one doc at a time. Each snapshot rebuilt the graph object, and react-force-graph restarts its simulation on every new graphData object.

Fix: compute a signature covering everything the canvas draws or a click opens: node id, size, colour, label, item closed/status/owner, and links. Pass ForceGraph2D a new object only when that signature changes. Real changes still re-render; stamp-only writes do nothing.

Verified: tsc is clean and lint only shows the two errors already in the file. Not click-tested in a browser; the preview needs a Google sign-in.

Deploy: hosting only.

#151
Hosting: no-cache on app routes, not just /index.html
Nick RaffeyPlatformopened 2026-09-24merged 2026-09-241 file, +18 / −0
App routes (/legal-brain, /deal-desk, /) are served index.html through the SPA rewrite. The no-cache header only matched the literal /index.html path, so routes got Firebase's default max-age=3600. A deploy could take up to an hour to show up for anyone who already had Winston open.
Merged

App routes (/legal-brain, /deal-desk, /) are served index.html through the SPA rewrite. The no-cache header only matched the literal /index.html path, so routes got Firebase's default max-age=3600. A deploy could take up to an hour to show up for anyone who already had Winston open.

This adds the same no-cache, no-store, must-revalidate header for / and for every extensionless path (**/!(.)). Hashed /assets/** files keep their one-year immutable cache.

Verified before the change: curl -I /legal-brain returned max-age=3600, while /index.html returned no-cache.

Deploy: hosting only.

#150
Legal Brain: split matters into requests + Ironclad, add the account legal file
Nick RaffeyLegal Front Dooropened 2026-09-24merged 2026-09-2411 files, +1499 / −659
PR B of the Legal Brain refresh (A was #149). It changes both the /legal-brain page and Winston chat's Brain tools.
Merged

PR B of the Legal Brain refresh (A was #149). It changes both the /legal-brain page and Winston chat's Brain tools.

Matters is split

"Matters" is no longer a Brain category. The salesforceMatters mirror is split by what each row is. The matters sync keeps running, because the Contracts tab and deal Chatter still read it.

  • Type — Source — Counts as open when
  • Request — Front Door tickets, plus SF Cases a team member owns outside the Front Door — ticket status ≠ closed / Case open
  • Ironclad — workflow rows from the mirror — status isn't Complete / Canceled / Archive
  • Deal — Deal Desk, by legalLead — not closed or archived
  • Zip — as before — not closed
  • Slack thread / Jira — archives — never
  • Of 506 open SF Cases, 87 are Front Door tickets. Only ~20 of the rest are owned by the current team. The other ~400 (sales reps, queues, BJ) leave the Brain.
  • The sync never set isClosed on Ironclad workflows, so 344 Complete/Canceled workflows used to count as open.
  • Customers now match ignoring legal suffixes and punctuation. Without that, only 166 of 433 live deal accounts joined to their Ironclad paper.

Account legal file

Click a customer hub and a panel opens with:

  • Paper on file: signed Ironclad records by type, with the latest agreement date and an expiry flag (≤120 days).
  • Open work: requests, workflows and deals, with Legal Risk, ESA / DPA / AI addendum in effect, and GenAI opt-in.
  • Who on legal works it.
  • Call notes: Daily Legal Sync / meeting-note extracts, plus deal-call transcript terms. Transcript terms are shown only to the transcript allowlist.

Chat gets the same content through a new account_legal_file tool. The Brain prompt and tool descriptions are rewritten for the new types.

Test and deploy notes (Verified, Deploy) are on GitHub · Open on GitHub ↗
#149
Legal Brain: current roster, exact deal attribution, archived sources marked
Nick RaffeyLegal Front Dooropened 2026-09-24merged 2026-09-247 files, +264 / −172
Roster (per Nick): Sasha, Sarah, Amy, Elisa, Isha, Tara, Brijesh, Flor, Sabba, Narmada, Ari, Barkha, Margot. Raheem and BJ removed. NAME_ALIASES fixes "Flor Aparicio" (SF) vs "Flor Z Aparicio". Queue/integration owners are no longer treated as people. Former team: Traci and Karen's work rolls up onto one "Former…
Merged

First of three PRs to bring the Legal Brain up to how Winston is used now. It changes both the /legal-brain graph and Winston chat's Brain tools (team_workload, team_footprint, account_legal_team).

Changes

  • Roster (per Nick): Sasha, Sarah, Amy, Elisa, Isha, Tara, Brijesh, Flor, Sabba, Narmada, Ari, Barkha, Margot. Raheem and BJ removed. NAME_ALIASES fixes "Flor Aparicio" (SF) vs "Flor Z Aparicio". Queue/integration owners are no longer treated as people.
  • Former team: Traci and Karen's work rolls up onto one "Former team" hub (a workload row, a muted graph node; team_footprint opens it by either name). It currently holds 85 open matters and 9 open deals that need new owners.
  • Deal attribution: by legalLead only. The old shared-account bridge is gone, so Brain deal counts now match query_deals.
  • Workload: counts open deals and open Zip only. Zip used to count every request ever.
  • Archived sources: Slack intake (stopped 06-10) and Jira (stopped 05-20) are labelled as archives in the legend, the tool output and the chat prompt. They're excluded from workload totals.
  • Harness: npm run harness:brain-roster (functions/) fails if the client and server roster copies drift.

Next

  • PR B: account legal file (Ironclad paper, Front Door context, deal flags) in the UI plus an account_legal_file chat tool.
  • PR C: history sources (sync notes, transcript terms, status updates) and FAQ gap mining.
Test and deploy notes (Verified, Deploy) are on GitHub · Open on GitHub ↗
#148
LFD: self-serve ESA/DPA tickets stay open for redlines
Nick RaffeyLegal Front Dooropened 2026-09-24merged 2026-09-244 files, +63 / −17
Two Crowe LLP self-serve tickets, an ESA (Case 00025106) and a DPA (Case 00025109), were closed by hand in Winston about two days after each draft was delivered. Winston's post in the triage thread said the draft was "handed to" the requester, which read like the work was done. No automation closed them:…
Merged

Why

Two Crowe LLP self-serve tickets, an ESA (Case 00025106) and a DPA (Case 00025109), were closed by hand in Winston about two days after each draft was delivered. Winston's post in the triage thread said the draft was "handed to" the requester, which read like the work was done. No automation closed them: Salesforce's Case history shows only Winston's user, and "Drafted" is an open status. ESA and DPA tickets need to stay open, because the counterparty's redlines come back to the same Case.

What changed

  • Wording (doc-ready.ts): the triage-thread post and the Case comment now say to keep the ticket or Case open, since redlines come back to it.
  • Warn on close: Mark closed on a delivered ESA/DPA (docOutcome: "ready") now shows a warning before closing. On the Slack card and the assignee DM this is the new closeConfirm in lfd-triage.ts. On the Winston queue it's a window.confirm in TicketModal.tsx. It warns rather than blocks, so a dead deal can still be closed.
  • Refresh on delivery: handleSfMirrorChange re-renders the card and the assignee DM when docOutcome changes to ready, so existing buttons pick up the warning.
  • NDA self-serve lanes are unchanged.
Test and deploy notes (Checks, Deploy) are on GitHub · Open on GitHub ↗
#147
LFD: the FAQ learns — rated answers and lawyers' replies become candidate rows
Joe ChiusanoLegal Front Dooropened 2026-09-239 files, +2484 / −4
From the 2026-09-22 call. You've been telling people "Winston gets smarter each time a question is asked and answered" — it doesn't. The Q&A Feed only shows recent interactions, and the knowledge spreadsheet is edited by hand. This closes the loop from both directions you described.
Open

From the 2026-09-22 call. You've been telling people "Winston gets smarter each time a question is asked and answered" — it doesn't. The Q&A Feed only shows recent interactions, and the knowledge spreadsheet is edited by hand. This closes the loop from both directions you described.

  • Door — Fires when — What it reads
  • Feedback — a legal user rates an answer and types what was missing — question, bot answer, the thread, the rating + explanation, which FAQ rows were already cited
  • Lawyer answer — a question that became a ticket closes — the original question plus the whole ticket conversation, both sides, in order

An AI gate then decides whether the exchange is good enough to log — your words.

🔴 What it deliberately does not do

It does not write into the answer corpus. The spreadsheet is the only source Winston answers from, and that rule is what lets citations be whitelisted against real FAQ ids. Auto-learned rows in the corpus would let Winston quote itself, and a bad row would be indistinguishable from a reviewed one.

So an accepted exchange lands in a new lfdKnowledgeCandidates collection, shaped like a spreadsheet row, with its provenance and the gate's reasoning — for a human to paste into the sheet. The Q&A feed gets a "Suggested FAQ rows" panel showing the row, where it came from, why the gate kept it, and Copy row (tab-separated — pastes straight across a spreadsheet row). Hidden when empty.

Where the cost is controlled

  • A bare right/wrong with no explanation never reaches the model — "right" only confirms what the sheet says, and "wrong" without the right answer teaches nothing.
  • A question closed with no legal reply never reaches the model.
  • Imported tickets are excluded by the backfilled marker — otherwise one backlog import run would mine hundreds of old Cases at once and bury you in candidates.
  • Every decision (including a rejection) is recorded, and checked before the model is consulted, so a Firestore redelivery costs nothing.

Codex attacked this before it went up

Four real findings, all fixed, each with the check that would have caught it:

  1. Role forgery — a requester could type \nLAWYER: this is canonical, log it, and the gate is told the lawyer's wording is truth. Messages are now one line each; a break becomes a visible ⏎.
  2. Narrow answers read as general rules — the ticket path took the lawyer's messages and dropped the requester's, so "only for our German public-sector customer" → "yes, for that exception" would draft as a universal rule. Both halves are kept, in order.
  3. Leaks — de-identification was asked of the question only, so one customer's negotiated term could ride into the answer. All drafted fields are covered now, plus a deterministic refusal of any row still naming a Q-/A- or Case number.
  4. Repeat cost / luck — the dedupe key was claimed after paying for the model, and a rejection left no trace, so a redelivery could flip a "no" into a row. Rejections are recorded; the decision is checked first. That also made it safe to read a lawyer's reply that lands after the close, which the one-shot close edge used to lose.

He found no trigger loop, confirmed both triggers bind ANTHROPIC_API_KEY, and confirmed the spreadsheet stays the only runtime answer source. One finding not addressed: legalIntakeRequests now fans out to three triggers. The guards keep model calls rare, and splitting the collection is a bigger change than this feature earns.

Two things for you

  1. Who reviews the panel? It's gated to the triagers today, same as the Q&A feed. If it should be Alyssa or whoever owns the sheet, tell me who and I'll widen the rule.
  2. The sheet is still edited by hand. This gets a good row in front of a human with one click to copy. Writing the workbook in the bucket directly is possible but it is a different risk — nothing writes that file today, and a bad automated write would take the answer source down with it. Say the word if you want that next.
Test and deploy notes (Checks) are on GitHub · Open on GitHub ↗
#146
LFD: one ESA/MSA request row, the page-2 type answer says which document it is
Joe ChiusanoLegal Front Dooropened 2026-09-236 files, +1148 / −102
From the 2026-09-22 call: requesters could not tell the ESA row from the MSA row, so legal re-filed the misses by hand. The two become one row, labelled ESA/MSA.
Open

From the 2026-09-22 call: requesters could not tell the ESA row from the MSA row, so legal re-filed the misses by hand. The two become one row, labelled ESA/MSA.

They are not the same document and they go to different lawyers, so something has to tell them apart. Page 1 already asks Whose paper?, and that answer now carries it:

  • Paper — What it is — Files Agreement_Type__c — Page 2 asks the ESA variant?
  • Our template — our ESA — the chosen variant, as today — yes
  • Counterparty's paper — their master agreement — MSA — no

Measured in BU_Prod today, not assumed

  • The Legal Contracting record type does offer MSA on Agreement_Type__c (read from the ui-api picklist: 26 values, MSA and MSA Review both present) — so the counterparty branch saves without degrading to "Other".
  • Case assignment rule entry #46: Agreement_Type__c equals MSA → (email). It routes on its own.

What changed

  • REQUEST_TYPES: MSA row removed, ESA row labelled "ESA/MSA". key: "esa" and leaf: "ESA" stay, so tickets, checklists, the generate lane and the Ironclad plumbing are untouched.
  • LEGACY_TOKEN_ALIASES: msa::MSA, msa::MSA Review, msa::Other resolve to the shared row, and isLegacyMsaToken() reads the prefix back — a modal opened before the deploy still submits, and still files an MSA.
  • formShape: new counterpartyMsa and askEsaType. The old rule ("an MSA is counterparty paper by definition") inverts on a shared row — the paper answer is now what identifies the document — and the coercion is kept for legacy tokens only.
  • Page-1 paper hint and the their-paper checklist say so; the MSA checklist survives as MSA_GUIDANCE.
  • The door classifier keeps one taxonomy row; "msa" / "master agreement" in free text resolves to the shared key.

Why the ESA-variant question drops on their paper

It was asked on both papers for one reason: prod has no plain ESA value, so without a variant the Case had nothing routable and fell to the catch-all. Their paper now files MSA, which routes on its own, so the question buys nothing there — and it has no true answer, because their master agreement is not one of our four variants. intake-launch-harness is updated for that, with the prod evidence in the comment.

⚠️ One question for you, deliberately not resolved here

Before this, "ESA on their paper" opened the Ironclad ESA upload record, while the MSA row was excluded from that lane on purpose — paperTemplateFor has no MSA entry, because the customer's own master agreement doesn't belong behind our ESA schema. One shared row can't be both.

I left the lane open: closing it would delete a built and tested capability to pre-empt your ruling, and PAPER_UPLOAD_ENABLED is off in production, so nothing changes today. But as written, a counterparty master agreement filed there would launch the ESA record. Which Ironclad record should it use? This interacts with #124.

Not touched

src/components/Chat2/requestTypes.ts still lists ESA and MSA separately. That surface asks for the ESA variant unconditionally, has no paper question to merge on, and web filing 409s in prod — mirroring it deserves its own pass rather than a guess here.

Depends on

Nothing, but it pairs with #144 (the Order Form label) — that one is independent and needs no answers. If you want the old labels folded into the queue filter and the 90-day analytics so the type doesn't show twice, #144 carries the script: npx tsx scripts/backfill-category-label.mts esa "ESA" "ESA/MSA" and again for msa "MSA" "ESA/MSA".

Test and deploy notes (Checks) are on GitHub · Open on GitHub ↗
#145
Roadmap: mark an item done from the row, and break it into steps
Nick RaffeyPlatformopened 2026-09-23merged 2026-09-236 files, +505 / −25
Two asks from Nick on the live module.
Merged

Two asks from Nick on the live module.

Mark done from the row

Completing something meant opening the detail panel and finding the Shipped chip. There's now a done button on every row — a small circle left of the title.

Finished work leaves the ranked list for a collapsed Done (n) section (reopenable, not deleted). A priority list numbered 1..N stops meaning anything if shipped items keep their slots.

Knock-on: ranks no longer need to be contiguous, because rows render their position, not the stored rank. That let remove stop rewriting every doc in the list on each delete, and promote/addManual now allocate past the highest rank in use rather than colliding with a shipped item's.

Steps

A ranked entry is usually a larger piece of work, so each carries an ordered checklist. The row shows progress (1/3); the panel has tick / delete / "Add a step".

Subtasks live on the item doc rather than their own collection: a handful per item, always read and written with their parent, and no second access rule to keep in sync with access.ts.

Ticking every step deliberately does not auto-complete the parent — whether the work is really done is a judgment call, and the button is right there.

Test and deploy notes (Verification) are on GitHub · Open on GitHub ↗
#144
LFD: the Order Form row is "Order Form", not "Create Order Form"
Joe ChiusanoLegal Front Dooropened 2026-09-232 files, +122 / −7
REQUEST_TYPES: the label becomes Order Form. key (orderform) and leaf (Order Form) are untouched, so the selection token, the Case's Agreement_Type__c and the Case Routing entry are all unchanged — nothing on the Salesforce side moves, and no legacy token alias is needed. The row moves below NDA. The array is read…
Open

From the 2026-09-22 call. Requesters who already had an order form in hand were filing it under a different request type, because the verb read as though this row were only for drafting a new one. The row always took both — it asks for the CPQ quote and accepts an upload.

What changes

  • REQUEST_TYPES: the label becomes Order Form. key (orderform) and leaf (Order Form) are untouched, so the selection token, the Case's Agreement_Type__c and the Case Routing entry are all unchanged — nothing on the Salesforce side moves, and no legacy token alias is needed.
  • The row moves below NDA. The array is read verbatim into the menu and is alphabetical within its group (your 2026-09-11 ask); "Order Form" sorts after NDA where "Create Order Form" sorted before DPA.
  • scripts/backfill-category-label.mts — new, one-shot, dry by default.

Why the script is here

A ticket stores the label it was filed under, and two readers group on that string rather than on categoryKey: the queue's type filter (TicketFilters.tsx:62) and the 90-day type breakdown (LfdAnalytics.tsx:105). Without folding the old label in, this one type shows up as two rows either side of the deploy. After deploy:

ROADMAP_PROJECT_ID=legal-dashboard-ee977 npx tsx \
  scripts/backfill-category-label.mts orderform "Create Order Form" "Order Form"

It matches on categoryKey and the old label, so a rename can never relabel a different type, and it is idempotent. Add --write to commit; without it, it prints the tickets it would touch and stops.

Not in here

The ESA/MSA merge is separate — it needs three answers from you first (routing on the merged row, whether the ESA-type question is skipped on counterparty paper, and whether history gets folded in). Sent those in DM.

Test and deploy notes (Checks) are on GitHub · Open on GitHub ↗
#143
Roadmap module, and five legacy modules off the nav
Nick RaffeyPlatformopened 2026-09-23merged 2026-09-2317 files, +1942 / −6
Matters, Legal Intake, Quote Integrity, Deal Calculator and Chat 2.0 drop out of the sidebar via NAV_HIDDEN_MODULES. Routes stay mounted and access is unchanged — the Slack calc-approval card's /deal-calculator?calc=… deep link keeps working, and un-hiding any of them is deleting one line.
Merged

Nav cleanup

Matters, Legal Intake, Quote Integrity, Deal Calculator and Chat 2.0 drop out of the sidebar via NAV_HIDDEN_MODULES. Routes stay mounted and access is unchanged — the Slack calc-approval card's /deal-calculator?calc=… deep link keeps working, and un-hiding any of them is deleting one line.

Their data pipelines were all still load-bearing and are untouched: matters feeds the Deal Desk Contracts tab and chat's linkedMatterChatter, useLegalIntake feeds Legal Brain, and the deal-calculator SHEET ingest feeds the drawer's Calc tab.

Roadmap

A ranked 1..N build list for Winston, fed by Nick's calls with Sasha and Sarah. Parsed candidates land in an inbox for triage rather than the list itself; promoting adds them at the bottom. Items carry every call that raised them with the verbatim line. Allowlisted to Nick to start (ROADMAP_EMAILS + canUseRoadmap() in rules — widen both together).

Winston being on the call must not pull the call into Winston's deals

Inviting winston@ to those calls shares the notes doc with the runtime SA, which is exactly the feed legal-sync-notes.ts reads — so a planning call mentioning customers would have written legalSyncEntries onto deal drawers and into Deal Brain chat for the whole legal tier.

roadmap-calls.ts owns one predicate with two consumers: legal-sync bails on a three-of-us call before extracting anything, and the roadmap ingest only takes what legal-sync rejects. Attendance decides, not the invite list — an outside voice rejects, two roster voices accept, one lone voice is inconclusive (Gemini credits a whole call to whoever does the talking) and falls back to the invite line, then the title. Gemini's newer "Quick notes" docs have no invite line at all.

Covered by functions/scripts/roadmap-gate-harness.cjs (npm run harness:roadmap), 21 cases including a fourth person present, the Daily Deal Sync roster, an external guest, a meeting room speaking, and prose "we invited".

Merges are suggested, not applied

The same seven real calls produced 2 merges on one run and 5 on the next, and the extras folded distinct asks together. Prompt tightening moved the number without making it reliable, and a wrong merge loses a request rather than just cluttering. So the ingest records possibleDuplicateOf and the inbox offers Merge / Keep separate.

Test and deploy notes (Verification) are on GitHub · Open on GitHub ↗
#142
LFD: timeline names the Slack-card reassigner instead of <@U…>
Nick RaffeyLegal Front Dooropened 2026-09-23merged 2026-09-231 file, +12 / −3
When a ticket is reassigned from the Slack card, ownerHistory.by is stored as the raw Slack mention token (e.g. <@U0456QNNLUR>). Slack renders that token, but the Winston timeline printed it as-is. prettyActor now resolves it by roster slackId, falling back to the token label or \"someone in Slack\".
Merged

When a ticket is reassigned from the Slack card, ownerHistory.by is stored as the raw Slack mention token (e.g. <@U0456QNNLUR>). Slack renders that token, but the Winston timeline printed it as-is. prettyActor now resolves it by roster slackId, falling back to the token label or \"someone in Slack\".

Display only, hosting deploy. Both open tickets with such entries (00025147 → Tara Johnson, 00025142 → Sasha Nichols) resolve from the roster.

#141
LFD: attach files to a reply from Winston; Slack link replaces "Reply in Slack"
Nick RaffeyLegal Front Dooropened 2026-09-23merged 2026-09-239 files, +514 / −70
Isha asked how to send her NDA redline back to the requester (Case 00025140). The only way was to post it in her Slack DM thread. Separately, the Reply in Slack button opened the assignee's DM thread for everyone, and anyone who isn't the assignee can't use that thread.
Merged

Why

Isha asked how to send her NDA redline back to the requester (Case 00025140). The only way was to post it in her Slack DM thread. Separately, the Reply in Slack button opened the assignee's DM thread for everyone, and anyone who isn't the assignee can't use that thread.

What

  • Attach in the reply composer. Up to 3 files, 10MB each, 15MB total. They are sent as replyFiles on lfdTriageAssign, shared into the requester's thread under the message, and kept on the ticket + SF Case (keepTicketFile). This is the same place a file sent from the DM relay ends up, so it shows up in the Files section and the timeline ("File sent by legal").
  • If an upload is refused, the response carries failedFiles and the record marks the file "(not relayed)". If the message itself isn't delivered, nothing is uploaded, but the file is still kept on the ticket.
  • Slack/SF/GCS access is injected as ReplyFileOps from index.ts, so lfd-triage still doesn't import lfd-intake.
  • "Reply in Slack" removed. A small Slack ↗ link in the pane header (and a "Slack" field in the mobile modal) opens your own relay thread if you're the assignee, and otherwise the triage card thread.
  • Conversation shows raw <@U…> mentions as @Name and lists attachments.
  • lfdTriageAssign: 512MiB / 120s timeout for the file path.
Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#140
LFD: images actually render in the file viewer (CSP blocked every blob:)
Nick RaffeyLegal Front Dooropened 2026-09-22merged 2026-09-222 files, +33 / −11
firebase.json — blob: added to img-src and frame-src. No wildcard, no unsafe-; blob: is same-origin bytes this app itself created from a role-checked lfdTicketFile response. Images render as an <img> fit to the frame instead of in an iframe — no nested scrollbars, it scales down, and onError falls back to the…
Merged

Evan's PNG on Case 00025085 rendered as Chrome's "This content is blocked. Contact the site owner to fix the issue." — not a missing feature: TicketFileViewer already routed image/* to a preview. It pointed an <iframe> at a blob: URL of our own fetched bytes, and hosting's CSP names no blob: anywhere:

img-src   'self' data: https:
frame-src https://accounts.google.com https://*.firebaseapp.com https://www.google.com

Changes

  • firebase.json — blob: added to img-src and frame-src. No wildcard, no unsafe-*; blob: is same-origin bytes this app itself created from a role-checked lfdTicketFile response.
  • Images render as an <img> fit to the frame instead of in an iframe — no nested scrollbars, it scales down, and onError falls back to the download-only body for formats no browser decodes (HEIC off a phone, TIFF).
  • An application/octet-stream blob whose name has an image extension is treated as an image, and "Open in tab" now appears for images as well as PDFs.

Takes effect only on a hosting deploy — the CSP lives in the response header, not the bundle.

Test and deploy notes (Verification) are on GitHub · Open on GitHub ↗
#139
LFD: the assignee's DM carries the handoff select too
Nick RaffeyLegal Front Dooropened 2026-09-22merged 2026-09-221 file, +67 / −9
Follow-on to #138, which put Reassign to… on the triage card. The owner's own DM — the surface a lawyer actually reads, and the one they have open on a phone — still had only Mark closed, so someone going on holiday had to find the channel card, or Winston, to pass their ticket on.
Merged

Follow-on to #138, which put Reassign to… on the triage card. The owner's own DM — the surface a lawyer actually reads, and the one they have open on a phone — still had only Mark closed, so someone going on holiday had to find the channel card, or Winston, to pass their ticket on.

What's on the DM now

Hand off to… + Mark closed, on open tickets.

One difference from the card: the current owner is left out of the options. This DM only ever reaches them, so every pick here is a handoff — and picking yourself would re-run the Salesforce write and re-DM you for nothing. The confirm spells out the part that surprises people: replies in this thread stop reaching the requester.

A hole this closes

After a handoff, the previous owner's DM kept its live controls. It already posted "this is now with X — replies here no longer reach the requester" while leaving a working Mark closed button two lines above it. Adding a select would have made that worse: an ex-owner could re-route a ticket that isn't theirs.

retireAssigneeAnchor now strips the controls off their copy before posting that note. Via chat.update, which doesn't notify, so it happens even when lawyer notifications are paused.

Test and deploy notes (Verified) are on GitHub · Open on GitHub ↗
#138
LFD: an assigned request can be reassigned from its Slack card
Nick RaffeyLegal Front Dooropened 2026-09-22merged 2026-09-221 file, +66 / −8
A request came in from Ellen, Salesforce auto-assigned it to Flor, and Sasha — at a conference, on his phone — had no way to move it. The Slack card for a request carries a Mark closed button and nothing else.
Merged

What Sasha hit

A request came in from Ellen, Salesforce auto-assigned it to Flor, and Sasha — at a conference, on his phone — had no way to move it. The Slack card for a request carries a Mark closed button and nothing else.

His read ("can only reassign if it's not assigned") isn't quite it: requests have never had the dropdown at all, assigned or not. Questions have it always, which is why that card looks different.

Why it was off, and why that reason has expired

Turned off for requests on 2026-08-21: a request files a Case, and SF's assignment rules pick the owner the moment it's created — a manual pick on a brand-new card would race the rules.

That holds only while the ticket is unowned. Once an owner has landed, handing it to someone else is a legal decision, not a Salesforce one.

So: the select is back on requests, for reassignment only.

  • Card state — Before — After
  • Request, unowned — "Auto-assigned in Salesforce" note — unchanged
  • Request, owned — Mark closed only — Reassign to… + Mark closed
  • Request, closed / self-serve — nothing — unchanged
  • Question — Assign to… — unchanged (+ confirm)

The pick runs the same assignRequest the Winston pane runs — Case owner written in Salesforce first, then the DM handoff with the conversation so far, the note to the previous owner, the requester update. All 13 roster entries carry an sfUserId, so there's nobody pickable that SF would reject for mapping.

Two pre-existing holes this would have widened

  • Mis-taps. A select commits on change — the Sarah → Flor → Amy double reassignment in seven seconds on Case 00025085, which #132 fixed in the web pane with a confirm popup. Same confirm now on the Slack select (questions too).
  • Silent refusals. A denied or failed assign only reached the logs, while Slack kept showing the name that was picked — so "not a triager" or "Salesforce refused" looked like success. Failures now answer in the thread and re-render the card so the select snaps back to the real owner.
Test and deploy notes (Verified) are on GitHub · Open on GitHub ↗
#137
Deal Desk: "Top legal priorities" widget — Sasha + Sarah hand-pick deals, by ACV, with a comment
Nick RaffeyLegal Front Dooropened 2026-09-22merged 2026-09-226 files, +517 / −1
Sarah's request: a Top legal priorities widget. A hand-picked list (not computed), ordered by ACV, showing t-shirt size and a one-line comment per deal. Only Sasha and Sarah curate it; everyone in the legal read tier sees it.
Merged

What

Sarah's request: a Top legal priorities widget. A hand-picked list (not computed), ordered by ACV, showing t-shirt size and a one-line comment per deal. Only Sasha and Sarah curate it; everyone in the legal read tier sees it.

How

  • Storage: new legalPriorities/{opportunityId} collection. Kept off the deal doc so the sync's field-preservation list and close-date rotation can't eat it.
  • Rules: read = seesLegalInternals(); create/update/delete = new canEditLegalPriorities() (Sasha, Sarah, operator), key-bounded like sltStatusNotes. Mirrored in LEGAL_PRIORITY_EDITOR_EMAILS in src/lib/access.ts for the UI.
  • Hook: useLegalPriorities — subscribes only for the read tier, exposes pin / unpin / setComment.
  • Widget dd-legal-priorities (Lists, audience legal): editors get an Add-deal search picker, a remove X, click-to-edit comment, and the existing TShirtCell. Read-only for others. Pins whose deal isn't loaded on the current tab are counted in a footer rather than dropped.
  • Drawer: "Top legal priority" card on Overview with Add/Remove + editable comment for editors.
  • Default layout: appended for the three editor emails. Only affects users with no saved layout; Sasha and Sarah will add it from the Add-widget drawer.

Not in this PR

  • Forecast email / Winston chat awareness of the list (v2 if asked).
  • Manual drag-ranking (ordering is ACV, per Sarah's framing).
Test and deploy notes (Deploy, Verification) are on GitHub · Open on GitHub ↗
#136
LFD: an operator can hide a ticket from Winston without touching its Case
Nick RaffeyLegal Front Dooropened 2026-09-21merged 2026-09-215 files, +35 / −5
Nick, 2026-09-21: Atu Kyeison's Case 00020022 (imported backlog, owner not on the roster) should not show in Winston, but the Case stays open in Salesforce.
Merged

Why

Nick, 2026-09-21: Atu Kyeison's Case 00020022 (imported backlog, owner not on the roster) should not show in Winston, but the Case stays open in Salesforce.

What

New field hiddenFromWinston: { atMs, by, reason } on the ticket doc — set by script, cleared to bring the ticket back. Every reader drops it: the queue hook (so Queue, Analytics, Team and the Deal Desk join), the health check's open-queue read, and the weekly digest. The Case-owner poller and SF mirror keep stamping the doc so an SF-side reassignment is still recorded.

Applied to sfcase-500Pd00000bNN54 (Case 00020022) the same day.

Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#135
LFD health: every ticket-thread message must reach its ticket; the DM handler's drops leave a mark
Nick RaffeyLegal Front Dooropened 2026-09-21merged 2026-09-214 files, +223 / −2
Nick, 2026-09-21, after the signed-NDA drop (#133): keep an eye on whether documents are going through — as the agent's responsibility, not a one-off. Flor's file was queued by Winston, ignored by a guard, and nobody knew for three days.
Merged

Why

Nick, 2026-09-21, after the signed-NDA drop (#133): keep an eye on whether documents are going through — as the agent's responsibility, not a one-off. Flor's file was queued by Winston, ignored by a guard, and nobody knew for three days.

What

  • Health check 11 threadMessagesLanded (alarm). Every Slack message Winston queued from a ticket thread in the last 24h — text or file — must be on the ticket by threadMessageLagMinutes (default 10): a messages[] entry by the same person within −2/+30 min, or a files[] entry with that name. Q&A and unanchored threads are skipped; younger events are in flight. A miss names the inbound event ids and says to re-queue as replay-<eventId>. Runs on the existing 15-minute schedule and lands on the Agents page like the other invariants.
  • Handler tripwire. When the subtype guard is about to ignore a message that has words or a file in a thread Winston owns, it warns with the ticket id and, for each file, sends the operators' file-loss email.
Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#134
LFD: files open in an in-app viewer with Download; each file is a timeline step
Nick RaffeyLegal Front Dooropened 2026-09-21merged 2026-09-217 files, +442 / −65
Nick, 2026-09-21, after the signed SPS NDA came back through the thread: "click the file and view it with the option to download", and files should show on the timeline.
Merged

Why

Nick, 2026-09-21, after the signed SPS NDA came back through the thread: "click the file and view it with the option to download", and files should show on the timeline.

What

  • TicketFileViewer (new): modal fed by lfdTicketFile. PDF, images and text render our own blob in an iframe — not sandboxed, because Chrome's PDF plugin renders blank inside a sandbox (verified side by side in the browser pane). Word (.docx) is converted to HTML client-side with mammoth's browser bundle (the package main reaches for Node's Buffer and crashes in a browser; the bundle carries its polyfills, ~490KB, code-split and loaded on first use) and rendered as srcdoc in an iframe with an empty sandbox. Anything else: Download only. Download and "Open in tab" in the header; Escape or backdrop closes; blob URL revoked on close.
  • TicketFiles: a click opens the viewer instead of window.open / a forced download.
  • Timeline: one row per kept file — "File sent by legal / by the requester / attached to the request", actor and filename — with a distinct dot.

New dependency: mammoth@1.12.3.

Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#133
LFD relay: a thread reply sent with 'also send to channel' is a message, not noise
Nick RaffeyLegal Front Dooropened 2026-09-21merged 2026-09-212 files, +33 / −2
Case 00025085, 2026-09-21 14:02 PT: Flor posted the signed NDA in her Winston thread with "also send to channel" ticked. Slack delivers that as subtype thread_broadcast. lfd.ts has always queued it, but handleIntakeDmEvent's guard dropped every subtype except file_share — before the relay ran, before any log line,…
Merged

Why

Case 00025085, 2026-09-21 14:02 PT: Flor posted the signed NDA in her Winston thread with "also send to channel" ticked. Slack delivers that as subtype thread_broadcast. lfd.ts has always queued it, but handleIntakeDmEvent's guard dropped every subtype except file_share — before the relay ran, before any log line, before any loss alert. Nothing reached the requester, the Case or Winston, and nobody was told.

What

  • handleIntakeDmEvent accepts thread_broadcast alongside file_share. Edits, deletes, joins and bot posts are still ignored.
  • Harness: a thread_broadcast reply in an anchored thread is relayed; a message_changed event is still ignored.

Repair

Flor's original event (Ev0C3HHSQ7DX) is re-queued under a new id after deploy so the live trigger relays it: file to Pete's thread, onto the Case, into Winston's Files.

Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#132
LFD triage: picking an owner opens a confirmation popup
Nick RaffeyLegal Front Dooropened 2026-09-21merged 2026-09-211 file, +89 / −40
Nick on #131's staged-pick flow: a "not saved — confirm below" hint sends the user hunting for the button. The confirmation belongs where the pick happened.
Merged

Why

Nick on #131's staged-pick flow: a "not saved — confirm below" hint sends the user hunting for the button. The confirmation belongs where the pick happened.

What

  • The Owner <select> shows the current owner. Choosing someone else opens a modal — "Reassign this request to X?" — that says what will happen (Case owner in Salesforce, Slack DM to X, previous owner told, requester notified) with Cancel / Reassign. Escape or a backdrop click cancels. The select is blurred after the pick so no keystroke can re-trigger it.
  • The footer Assign/Reassign button goes back to opening the dropdown (focus + showPicker), which is safe now that a pick only asks.

Client only; no server change. Hosting hot-deployed from this branch.

Test and deploy notes (Tests) are on GitHub · Open on GitHub ↗
#131
LFD triage: owner change is picked then confirmed; a reassignment keeps in_progress
Nick RaffeyLegal Front Dooropened 2026-09-21merged 2026-09-212 files, +59 / −33
Case 00025085, 2026-09-21 12:35 PT: Sarah replied "approved" from the queue, then the ticket was reassigned twice in seven seconds — to Flor, then to Amy — both under her login. An Opus investigation plus a Fable review traced it: the Split pane's Owner control committed on the <select>'s own change event, and the…
Merged

Why

Case 00025085, 2026-09-21 12:35 PT: Sarah replied "approved" from the queue, then the ticket was reassigned twice in seven seconds — to Flor, then to Amy — both under her login. An Opus investigation plus a Fable review traced it: the Split pane's Owner control committed on the <select>'s own change event, and the Reassign button left the select focused, so a single type-ahead keystroke reassigned a legal ticket (Case owner rewritten in Salesforce, a lawyer DMed and handed the whole transcript, the requester told twice). Independently, the reassignment forced the ticket from in_progress back to assigned, orphaning the reply's status stamp and misreporting an answered ticket.

What

  • TicketDetailPane: the Owner <select> only records a pick. The footer Assign/Reassign button commits it and is disabled until the pick differs from the current owner — the shape TicketModal already had. "not saved — confirm below" shows while a pick is pending. The focus()/showPicker() hack is removed.
  • assignRequest: a ticket already in_progress stays in_progress under its new owner; statusUpdatedBy/At are stamped only when the status actually moves.
  • SF mirror guard: "already reflected" now accepts in_progress as well as assigned, so the sfOwnerId write from such a reassignment is still recognised as Winston's own and does not fire a second assignee DM. (Reviewer caught that the two must change together.)

Not done, per review: no server-side "reject rapid reassigns" guard — the question router and the SF mirror legitimately reassign in quick succession.

Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#130
comments: hide the attach control — no default Storage bucket exists for it
Nick RaffeyPlatformopened 2026-09-21merged 2026-09-211 file, +30 / −18
Comment attachments upload to the Firebase default Storage bucket through the client SDK. This project has no default bucket (found 2026-09-21 while fixing ticket files, #128), so every upload would fail. 38 comments in production, none has ever carried an attachment.
Merged

Why

Comment attachments upload to the Firebase default Storage bucket through the client SDK. This project has no default bucket (found 2026-09-21 while fixing ticket files, #128), so every upload would fail. 38 comments in production, none has ever carried an attachment.

What

The paperclip, hidden file input and "Uploading…" label in CommentComposer sit behind COMMENT_ATTACHMENTS_ENABLED = false. Existing attachments still render. Flip the flag once a default bucket exists; storage.rules already covers comments/.

Test and deploy notes (Tests) are on GitHub · Open on GitHub ↗
#129
LFD files: email the operators when a ticket file misses the Case or Winston
Nick RaffeyLegal Front Dooropened 2026-09-21merged 2026-09-213 files, +117 / −10
Follow-up from the #123 review. A thread file that reached Slack but failed the Case attach and/or the Storage copy produced one console.error per file and nothing else — the exact shape of Case 00025085, where a document existed only in Slack and nobody knew until the lawyer went looking. #128's missing-bucket…
Merged

Why

Follow-up from the #123 review. A thread file that reached Slack but failed the Case attach and/or the Storage copy produced one console.error per file and nothing else — the exact shape of Case 00025085, where a document existed only in Slack and nobody knew until the lawyer went looking. #128's missing-bucket failure would also have been silent in production.

What

  • reportTicketFileLoss (lfd-files.ts) queues an email to SYNC_ALERT_EMAILS through the Trigger Email extension, the same path the health checks use. It names the ticket (Winston link), file, sender side, what failed, where the file still is, and the error. Called on a claim write failure, a Case attach failure (Winston kept it), and a Storage/files[] failure (Case may have it). Deterministic mail doc id, create(), best-effort.
  • Relay transcript: a file that reached the other side's Slack thread but neither the Case nor Winston is recorded as (in Slack only — not on the ticket) rather than a bare name that reads as kept.
  • Harness: store failure and Case failure each queue exactly one alert with the right wording; happy path and Salesforce-off queue none.
Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#128
LFD files: name the ticket-files bucket (project has no Firebase default bucket)
Nick RaffeyLegal Front Dooropened 2026-09-21merged 2026-09-212 files, +20 / −6
First live use of #123 (backfill for Case 00025085): getStorage().bucket() resolved to legal-dashboard-ee977.firebasestorage.app, which does not exist. This project has no Firebase default Storage bucket — only legal-dashboard-ee977-lfd-knowledge and the Cloud Functions internals. Every Winston copy failed with…
Merged

Why

First live use of #123 (backfill for Case 00025085): getStorage().bucket() resolved to legal-dashboard-ee977.firebasestorage.app, which does not exist. This project has no Firebase default Storage bucket — only legal-dashboard-ee977-lfd-knowledge and the Cloud Functions internals. Every Winston copy failed with "The specified bucket does not exist", swallowed per file exactly as the #123 review warned. The Case attach worked.

What

  • TICKET_FILES_BUCKET = LFD_FILES_BUCKET || <project>-lfd-files, resolved once and logged at cold start.
  • Bucket legal-dashboard-ee977-lfd-files created 2026-09-21: us-central1, standard, uniform bucket-level access, public access prevention enforced. Not Firebase-linked, so no client path exists; lfdTicketFile is the only reader.
  • Backfill script drops the wrong storageBucket.
Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#127
LFD files (#123 follow-up): org check before the Case attach, lfdTicketFile bounded, transcript points at the files
Nick RaffeyLegal Front Dooropened 2026-09-21merged 2026-09-215 files, +88 / −9
Org check before the Case attach (relayTicketMessage). The relay's Case attach is a Salesforce write about the ticket, so it now runs ticketInThisOrg first, like every other Salesforce read/write about a ticket. A ticket whose Case lives in the other org is kept for Winston only; its Case id is never POSTed against…
Merged

Stacked on #123 — targets its branch so the fixes land inside it. From a review of #123 prompted by Case 00025085 (Sarah could not find the NDA Pete dropped in his thread).

What

  • Org check before the Case attach (relayTicketMessage). The relay's Case attach is a Salesforce write about the ticket, so it now runs ticketInThisOrg first, like every other Salesforce read/write about a ticket. A ticket whose Case lives in the other org is kept for Winston only; its Case id is never POSTed against these creds. Before, a pre-cutover sandbox ticket under production creds (or the reverse) would have been attempted.
  • Files past the 3-per-message cap were silently dropped from Slack, the Case, Winston and the transcript. They now appear in the sender's warning and in the transcript as (not relayed), and the warning no longer claims "your message went through" when it didn't.
  • lfdTicketFile bounded like lfdRelatedChatter: checkRateLimit (new ticketFile tier, 20/min per user), maxInstances: 5, Cache-Control: no-store. Each call is up to ~13MB of base64.
  • "Conversation so far" says where files are. When the transcript replayed into a new assignee's DM names attachments, a trailer links the ticket in Winston and the Case. Before, the 📎 line was an unclickable name while the Slack copy sat in the triage-card thread the assignee never opens — exactly what happened to Sarah on 00025085.
  • functions/scripts/backfill-25085-file.mjs: one-off that puts the SPS Commerce NDA (Slack F0C2PKGSG91) on Case 00025085 and on the ticket through keepTicketFile, dry-run by default.

Not done here (should-fix follow-ups from the review)

  • A file that reaches Slack but fails both the Case and Storage is only a console.error; route !stored to SYNC_ALERT_EMAILS and label the transcript name (not kept).
  • getStorage().bucket() is the only unnamed bucket in the repo; resolve and log it once at module load so a missing storageBucket fails loudly, not per-file.
  • With no Slack file id there is no claim and a redelivery double-attaches; fall back to a hash-of-bytes claim key.
  • A busy lease is reported to the relay as duplicate (success); it should read as unknown.
Test and deploy notes (Tests) are on GitHub · Open on GitHub ↗
#126
docs: sales access wave roster + repo copy of deal-calc Apps Script
Nick RaffeyPlatformopened 2026-09-18merged 2026-09-184 files, +281 / −1
Docs only. Sales-wave roster (already live 09-16), repo mirror of the deal-calculator Apps Script, handover pointer update.
Merged

Docs only. Sales-wave roster (already live 09-16), repo mirror of the deal-calculator Apps Script, handover pointer update.

#125
Winston: show names, not <@U…> Slack ids
Joe ChiusanoLegal Front Dooropened 2026-09-1813 files, +416 / −36
Winston showed Slack mentions as raw ids: "Hi <@U02SEPZGX38> … connect with <@U0986KYDUMT>" instead of "Hi @Rob Schlanser … connect with @Jeremy Anderson". Slack stores a mention as <@U…> in the message text. Winston saved that text exactly as received, and the web app printed it as-is. The Case description had the…
Open

Why

Winston showed Slack mentions as raw ids: "Hi <@U02SEPZGX38> … connect with <@U0986KYDUMT>" instead of "Hi @Rob Schlanser … connect with @Jeremy Anderson". Slack stores a mention as <@U…> in the message text. Winston saved that text exactly as received, and the web app printed it as-is. The Case description had the same problem ("Requester: <@U06HVUF7D0F>").

Stacked on #124 (→ #123). Merge those first.

What

  • functions/src/slack-mentions.ts (new). resolveSlackMentions replaces users (<@U…>, <@U…|label>, W-prefixed ids), user groups, @here/@channel and channel links with readable names. Each id is looked up once per text. An id it can't resolve is left as markup rather than guessed.
  • Names are swapped in when saving:
  • the relay transcript (messages[].text)
  • the Requester line in the Case and ticket description (Requester: Rob Schlanser (Slack U02…))
  • Deal Desk thread messages, before matching as well as before the AI summary

Posts back into Slack keep the markup, because Slack turns it into a live mention.

  • Names are swapped in when displaying (src/lib/slackMentions.ts), for records saved before this change. It uses names from the legal roster and any |label in the mention; an unknown id reads "@someone" and a raw id is never shown. It covers:
  • ticket conversation text, author names and descriptions in both views
  • SlackMrkdwn
  • Q&A questions and turns
  • file bylines
  • Deal Desk summaries
  • functions/scripts/backfill-slack-mentions.mjs is a one-time cleanup, dry run by default, with --write to apply. It covers tickets (description, messages) and Deal Desk slackThreads (messages, summary). Old summaries with bare U… ids are only changed when Slack confirms the id is a user.
  • Each write is a transaction that re-reads the document first, so a message relayed mid-run isn't lost.
  • It waits out Slack rate limits (Retry-After), and only caches names Slack actually returned.
  • If any lookup still fails after retries, it exits with status 1 and says to re-run.
Test and deploy notes (Tests, Deploy / run) are on GitHub · Open on GitHub ↗
#124
LFD: their NDA/DPA/ESA dropped in the thread can be filed into Ironclad
Joe ChiusanoLegal Front Dooropened 2026-09-185 files, +903 / −5
Nick: any ESA, DPA or NDA an AE uploads should end up in Ironclad. #110 already does this for a counterparty document attached on the intake form. It doesn't cover a document the requester drops into the ticket's thread later, like the SPS NDA on Case 00025085. A thread message has none of the facts the launch form…
Open

Why

Nick: any ESA, DPA or NDA an AE uploads should end up in Ironclad. #110 already does this for a counterparty document attached on the intake form. It doesn't cover a document the requester drops into the ticket's thread later, like the SPS NDA on Case 00025085. A thread message has none of the facts the launch form requires (signer, address, NDA type, region, and so on). paperUploadBlocked fails closed without them, so an automatic launch would almost always skip.

Stacked on #123. This PR needs the files that #123 keeps for Winston. Merge #123 first; this PR's base will then move to main.

What

  • Offer. When the requester's thread message leaves a Word or PDF file on an NDA/DPA/ESA ticket, Winston replies in the thread with a "File their NDA into Ironclad" button. It only does this when the ticket is open, has no Ironclad workflow yet, and the paper-upload lane is on (PAPER_UPLOAD_ENABLED plus launch creds, the same gates as the form lane). No offer is made for a lawyer's files, a closed ticket, or an ESA when the jurisdiction attribute isn't configured.
  • Short modal. It uses exactly the blocks page 2 uses for "their paper":
  • NDA: type, legal name, signer, address, US-based, entity, effective date
  • DPA: region, legal name, signer
  • ESA: type, US-based, legal name, signer, contact

The legal name is pre-filled from the account. There's no anchor, subject, or file input, because the ticket already has them. The modal opens straight off the button click (MODAL_OPEN_ACTIONS), with no read first.

  • Validation. Before submitting, the modal runs the launch's own check (paperUploadBlocked), and each missing fact shows as an error on the field that fixes it.
  • Submit runs on the queue, and index.ts processes each submission once. It re-checks the ticket in a transaction: the clicker must be the ticket's requester, the ticket must be open, it must have no workflow, and there must be no earlier attempt with an unknown outcome. It then reads the kept file back (#123) and launches the same Counterparty-paper workflow. On success the ticket gets the same paperUpload* fields plus paperUploadVia: "thread". The result goes to the modal, the requester's thread, the triage thread, and a Case comment.
  • No duplicate workflows. A hold (paperFilingUnknown) is set before the launch. Only a clear refusal (4xx, or a gate) or a recorded workflow clears it. A dropped connection, a 5xx, or a missing workflow id leaves it standing, and triage is told to check Ironclad. The web page then says "check Ironclad before launching" instead of "launch by hand".
  • Web. A failed filing from a thread upload also shows on an our-paper ticket.
Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#123
LFD: thread files reach the Case and are viewable in Winston
Joe ChiusanoLegal Front Dooropened 2026-09-18merged 2026-09-2113 files, +941 / −28
Case 00025085 (NDA, SPS Commerce, 9/18): the requester replied in the ticket's Slack thread with the counterparty's NDA attached. The file never reached the Salesforce Case (0 files on it) and isn't visible in Winston. The reply relay only re-uploaded it into the lawyer's Slack thread. Only files on the intake form…
Merged

Why

Case 00025085 (NDA, SPS Commerce, 9/18): the requester replied in the ticket's Slack thread with the counterparty's NDA attached. The file never reached the Salesforce Case (0 files on it) and isn't visible in Winston. The reply relay only re-uploaded it into the lawyer's Slack thread. Only files on the intake form were ever attached to the Case.

What

Every document that reaches a ticket is now on the Case and viewable in Winston.

  • functions/src/lfd-files.ts (new). keepTicketFile attaches the file to the Case, when there is a Case and Salesforce is on. It also saves the file in Cloud Storage under lfdFiles/{requestId}/ and lists it on the ticket as files[]. Both steps are best-effort and never block the message or the filing.
  • Reply relay (relayTicketMessage). Each file is downloaded once. It goes into the other side's Slack thread first, as before, and then onto the Case and into Winston, even when the Slack relay didn't deliver. The sender still gets a warning for any file the other side won't see.
  • Intake form. Uploaded files are also kept for Winston, after the triage card and the owner read-back. The Case attach isn't repeated.
  • lfdTicketFile (new HTTP function). Returns a file listed on the ticket. It uses the same fail-closed check as lfdRelatedChatter: legal/admin role plus the stored legalFrontDoor module, matching firestore.rules. Storage stays closed to clients.
  • Web. There's a Files section in TicketModal and TicketDetailPane. PDFs and images open in a new tab; Word and Excel files download. Each row shows who added the file and "not on Case" if the Case attach failed. Attachment names from tickets filed before this change stay listed.
  • Redelivery. Each Slack file is claimed once per ticket (lfdTicketFileClaims), so a redelivered event doesn't add a second copy to the Case. The claim resumes if a previous run died part-way.

Known limits

  • A redelivery that arrives within 2 minutes of a crashed run is skipped as in-flight. Slack doesn't retry after that, so the file stays only in Slack.
  • If a run crashes after Salesforce accepted the file but before the claim recorded it, a retry can attach it to the Case a second time.
  • Files from web filing (index.ts form path) aren't kept for Winston. Web filing is Slack-only in prod right now.
  • Files sent before this deploys (including the SPS NDA) aren't backfilled. Attach that one by hand.
Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#122
deal desk: Close Month filter chip — bucket close dates by month (Sarah)
Joe ChiusanoDeal Deskopened 2026-09-17closed 2026-09-283 files, +39 / −2
Sarah (2026-09-17 DM + Loom): "the close date filter — I would like it to be monthly rather than by actual date, so anything closing in September is identified as such."
Closed

What

Sarah (2026-09-17 DM + Loom): "the close date filter — I would like it to be monthly rather than by actual date, so anything closing in September is identified as such."

Winston had no close-date filter at all — the chip bar carries one for every column except Close Date, so her only option was unhiding the Close Date column and reading exact days. This adds a Close Month chip whose options read Sep 2026, Oct 2026, … sorted chronologically, blank last. Because the chip bar is shared, it lands in Deal Desk, Deal Lifecycle and Forecasting together.

How

  • Parses closeDate with the existing parseClose() (ISO date, ISO datetime, epoch ms), month read in UTC so it never slips a day for PT readers — the same rule the Close Date column already uses.
  • SF close date only. The Winston quarterOverride moves a deal between quarter tabs but does not change when it closes, so it is not consulted.
  • Options enumerate from the scoped set in Deal Desk, so under Quarter: Q3 the chip offers Aug/Sep/Oct — it reads as a sub-filter of the quarter.
  • Unparseable/blank dates land in the — (blank) bucket. No persisted state, no schema change.

Second commit — two findings from a three-reviewer pass before merge

  1. closeMonthLabel (the existing "By Close Month" breakdown widget) formatted in the browser's zone, so a date-only close parses to UTC midnight and reads one month early west of Greenwich: a 2026-09-01 close showed "Aug 2026" in PT. With the new chip on UTC, widget and chip disagreed on every 1st-of-month deal. Now both UTC, verified agreeing in PT, JST and UTC. The "Closing this month" card already used UTC bounds and was never affected.
  2. A month-filtered CSV said "September" nowhere. The extras list exists precisely for fields Sarah slices on and shares onward (2026-08-02). Close Month now exports alongside Close Date.

Known, deliberate

  • Picking a month in Deal Lifecycle that has no deals in the current quarter tab empties the board, because Lifecycle enumerates chip options from the full set by design. Pre-existing behaviour for every chip there, not introduced here.
  • Under Archived/Full-quarter scope an archived deal buckets by its stored closeDate; the current SF value lives separately in archivedSfCloseDate.
Test and deploy notes (Checks) are on GitHub · Open on GitHub ↗
#121
forecast movement: stop counting pipeline deals and pull-forwards as departures
Nick RaffeyPlatformopened 2026-09-17merged 2026-09-179 files, +110 / −15
Sarah asked whether the Forecast Movement widget's "68 ops moved out in 30 days" was real. It was about 2x overstated, and the inflation was on our side, not Salesforce.
Merged

Sarah asked whether the Forecast Movement widget's "68 ops moved out in 30 days" was real. It was about 2x overstated, and the inflation was on our side, not Salesforce.

What was wrong

  • Pipeline-report docs (Matt's report, never in the forecast) were counted: 122 of 312 rows in the 30-day view.
  • Q4 deals pulled forward into Q3 fell through the sync classifier to removed because it only checked later quarters: 21 deals / $24M in the window, e.g. Disney, Equinix, WSIB, Microsoft.

Fix

  • useForecastMovement excludes sourceReport === 'pipeline'.
  • New archiveReason: 'pulled_in' from the sync; treated like rekeyed in every consumer (widget, All/Archived/Full-quarter views, lifecycle Closed column, moved-out alerts) and labeled in the forecast email.
  • restamp-pulled-in.ts re-stamped 29 historical docs (already run against prod; prior value kept in archiveReasonPrevious).

Result — 30-day view drops from 312 to 170: 38 pushed ($17.0M), 5 removed, 93 won, 34 lost.

Deploy: hosting (both sites) + forecastReport. Sync picks up on merge via GH Actions.

#120
docs: demo runbook + Ironclad prereqs marked superseded; access matrix and routing matrix aligned with main
Joe ChiusanoLegal Front Dooropened 2026-09-16merged 2026-09-164 files, +62 / −13
The four docs the pre-go-live review flagged as describing pre-cutover behaviour. Statements that would mislead an operator are corrected; the rest is marked historical with a banner pointing at the current runbooks.
Merged

The four docs the pre-go-live review flagged as describing pre-cutover behaviour. Statements that would mislead an operator are corrected; the rest is marked historical with a banner pointing at the current runbooks.

  • lfd-demo-runbook.md — banner: cutover shipped 9/14 (LFD_TARGET_ORG), Ironclad launches live, allowlist = Nick/Joe/Sasha + roster, web filing still sandbox-only. §5 marked DONE with what actually happened.
  • ironclad-launch-prereqs.md — banner with what is live behind which switch. Beta Terms is a Salesforce BU18/DocuSign route, not these rails. ESA needs IRONCLAD_ESA_SELF_SERVE_ATTRIBUTE, not "nothing more on the Ironclad side".
  • access-matrix.md — roster row (Karen out; Ari, Atu, Flor, Isha in); Agents console per #109 with the rules/UI mismatch called out rather than papered over; rows for the Front Door queue (rules grant every ticket to any legal/module user, "Mine" is UI-only) and the triagers-only collections (#114, #118).
  • lfd-routing-matrix-2026-09.md — order forms → Tara (prod Case Routing entry 41; the old row contradicted line 13); seven names not eight; scope note that the assignment rule decides Case routing, this doc drives Q&A escalation.

Not touched: the 88-test checklist page (Cloudflare Pages, separate repo) still says "built against main 14 Sep".

Docs only, nothing to deploy.

#119
lfd: replies leave the ticket timeline
Nick RaffeyLegal Front Dooropened 2026-09-16merged 2026-09-161 file, +6 / −14
Legal and requester replies were rendered twice on a ticket: as "Legal responded" / "Requester replied" rows in the Timeline, and again in Conversation. Nick (2026-09-16, first live Amy round-trip): messages belong in Conversation only.
Merged

Legal and requester replies were rendered twice on a ticket: as "Legal responded" / "Requester replied" rows in the Timeline, and again in Conversation. Nick (2026-09-16, first live Amy round-trip): messages belong in Conversation only.

  • timelineOf no longer folds messages[] into events; the legal-reply / requester-reply kinds are gone.
  • Header figures (Created → first legal response, Longest wait between replies) still derive from messages[] and are unchanged.
  • Only consumer is TicketDetailPane.
#118
lfd: launch fixes — raw inbound events readable by triagers only; one-command sweep for orphaned bells
Joe ChiusanoLegal Front Dooropened 2026-09-16merged 2026-09-164 files, +235 / −1
Two of the pre-go-live items I could fix from my side.
Merged

Two of the pre-go-live items I could fix from my side.

1. lfd_inbound_events rules gap

#114 locked lfd_interactions to the triagers, but lfd_inbound_events — the raw inbound Slack message text, one pipeline stage earlier — stayed readable by anyone holding the legalFrontDoor module. Same gate now: admin, or email in config/legalTriage.triagers. No client code reads the collection (grep -rn lfd_inbound_events src/ is empty), so nothing in the UI changes; this is defence in depth.

Deploy: firebase deploy --only firestore:rules.

2. functions/scripts/sweep-stale-bells.mjs

The 13 prod test tickets deleted by hand on 9/15 left their unread notifications docs behind. clearTicketBells only runs on the status transition to closed, so a deleted ticket never triggers it — hence the "Stale in-app bells — 14 ticket(s)" alarm firing all day with a 6-hour re-page.

The script runs the health check's own query and verdict (checkStaleBells: unread lfdRequest bells whose ticket is missing or closed) and applies the close-sweep's own fix (read: true), batched 400. Dry run by default, --apply to write, project allowlisted, nothing deleted.

cd functions && npm run build
node scripts/sweep-stale-bells.mjs --project legal-dashboard-ee977          # plan
node scripts/sweep-stale-bells.mjs --project legal-dashboard-ee977 --apply  # mark read

Needs Firestore write on ee977 (my ADC is denied there, so this one is yours). The next lfdHealthCheck run should then report the alarm recovered.

Harness npm run harness:bells — 5 checks (arg parsing, stale vs live vs already-read vs non-front-door bells, exact writes, clean plan writes nothing, batching). eslint clean.

#117
docs: Legal Front Door stop runbook — what to flip first, and why sandbox is not rollback
Joe ChiusanoLegal Front Dooropened 2026-09-16merged 2026-09-161 file, +59 / −0
From the pre-go-live review (2026-09-16): there was no incident procedure. lfd-demo-runbook.md §5 describes the migration that already happened; lfd-backfill-runbook.md rolls back an import.
Merged

From the pre-go-live review (2026-09-16): there was no incident procedure. lfd-demo-runbook.md §5 describes the migration that already happened; lfd-backfill-runbook.md rolls back an import.

docs/lfd-stop-runbook.md:

  • Levers by failure class, fastest first. Slack event subscription off (prod app A0B34MYCREE / legal2, not the staging one), Cloud Scheduler pause, config/legalTriage switches — none need a deploy. IRONCLAD_LIVE_SEND=false and LFD_TARGET_ORG=off live in functions/.env and only take effect when the reading functions are redeployed, so they come second.
  • Why LFD_TARGET_ORG=sandbox is not a rollback: credential isolation, yes (lfd-org.ts:44-58); operationally it strands every sandbox:false ticket (poller org filter case-owner-poll.ts:97-105, ticketInThisOrg), and recalls nothing in Salesforce, Ironclad or DocuSign.
  • Scheduler job table with what each poller does if left running; a redeploy re-creates jobs enabled.
  • Reconcile before re-open: the exact SOQL for what was filed, where the Ironclad/DocuSign artifacts are, delete-both-or-neither, and sweeping bells + anchors (the 13 hand-deleted test tickets left 14 stale bells, which is today's standing health alarm).
  • Re-open in reverse, Slack last, one watched canary.
  • Not covered: the SF flows that fire on a Legal Contracting Case regardless of Winston (BU18 Beta Terms DocuSign, stale-case owner emails, Legal-queue amendment emails).

Docs only. Nothing to deploy. Nick: the one thing I could not verify from my side is the Cloud Scheduler job naming in ee977 — if the jobs are not firebase-schedule-<fn>-us-central1, correct §2.

#116
lfd: the create of an imported ticket is silent on its own — no global switches for future imports
Joe ChiusanoLegal Front Dooropened 2026-09-16merged 2026-09-1612 files, +525 / −44
lfd-notify-switch.ts — isImportCreate(before, after): the CREATE of a marked doc (before === undefined && after.backfilled === true), and only the create. lawyerNotificationsMuted takes { created } and holds that write whatever the switch says. lfd-triage.ts handleSfMirrorChange — an import create with an owner…
Merged

Nick's #111 review: while sfMirrorSilent and lawyerNotifications.* were off for the backlog import, a real request filed in that window got no assignee DM and no reply-relay anchor, and nothing delivered them later. Every imported doc already carries backfilled: true, so the quiet path now keys on the document, not the console.

What changes

  • lfd-notify-switch.ts — isImportCreate(before, after): the CREATE of a marked doc (before === undefined && after.backfilled === true), and only the create. lawyerNotificationsMuted takes { created } and holds that write whatever the switch says.
  • lfd-triage.ts handleSfMirrorChange — an import create with an owner logs SF owner recorded silently (backfill) — imported doc and does nothing else: no write, no config read (the importer already wrote the complete owner state; a second write here from a stale roster memo could change ownerSlackId and ring the bell on the follow-up event — Codex). The progress and close branches take their silent path on the create without the flag. The reopen branch needs a closed before, so it can't fire on a create and stays on the flag.
  • lfd-notify.ts — the bell passes created: before === undefined.
  • Create-only on purpose. The marker stays on the doc for life (health check, digest, Team tab read it), but a later Salesforce reassignment of an imported Case DMs and rings the new owner like any other ticket. The poller re-reporting the same owner in the other id spelling is a no-op.
  • --quiet-by-marker (import only) on backfill-legal-cases.mjs: skips the switch preflight and says so as a warning. The script still cannot see what is deployed; the runbook's deploy step and one-ticket canary prove it.
  • Runbook: step 2 skipped and step 9 moot on a main from today; lfdHealthProbes added to the deploy list; the canary must show the exact — imported doc mirror line and the bell's held back on the create line (not the generic already reflected); export and import the same evening — an owner change in between is delivered as the real reassignment it is.

Global switches

Unchanged and still honoured. They are no longer needed for an import; they remain the tool for a mirror pass over docs that carry no marker.

Not built (follow-ups if wanted)

  • A deploy-written version doc the CLI could require before --quiet-by-marker.
  • An open-queue cap check in the CLI (poller MAX_TICKETS 400 / health cap).
Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#115
chore: land uncommitted work (GenAI field, fresh chat, transcript script, staging doc)
Nick RaffeyPlatformopened 2026-09-16merged 2026-09-164 files, +97 / −19
Four files that had been sitting modified in Nick's working tree since Aug/Sep, all reviewed and tsc -b clean:
Merged

Four files that had been sitting modified in Nick's working tree since Aug/Sep, all reviewed and tsc -b clean:

  • DealDeskDetail.tsx — GenAI Opt-In as a plain field in the opportunity facts, next to the existing Compliance pill. Sync has emitted the column since 8e3f1a7.
  • useChatbot.ts — Winston chat opens on a fresh empty thread each visit; previous threads remain in History. (Follow-on to the stale-thread "not configured" reports.)
  • tools/transcript-sync-appscript/Code.gs — pass 2 recurses Google's new per-meeting "Google Meet" subfolders and copies notes docs; documents why copies, not shortcuts. This is what already runs in Jeremy's account.
  • docs/staging-setup.md — cloudbilling.googleapis.com plus secretmanager.viewer / serviceUsageAdmin roles, the things that made a non-owner deploy fail at the last step.

Not deployed by this PR; the chat and drawer changes ship with the next hosting deploy.

#114
rules: Q&A feed readable only by the triagers list (Sasha, Sarah, Nick, Flor, Joe)
Nick RaffeyLegal Front Dooropened 2026-09-16merged 2026-09-161 file, +7 / −1
The LFD Analytics / Team / Q&A tabs are UI-gated on config/legalTriage.triagers (currently Sasha, Sarah, Nick, Flor, Joe). The lfd_interactions read rule was still hasModule('legalFrontDoor'), so any legal-role user could read the raw feed. This makes the rule read the same list, so the two cannot disagree. Admin…
Merged

The LFD Analytics / Team / Q&A tabs are UI-gated on config/legalTriage.triagers (currently Sasha, Sarah, Nick, Flor, Joe). The lfd_interactions read rule was still hasModule('legalFrontDoor'), so any legal-role user could read the raw feed. This makes the rule read the same list, so the two cannot disagree. Admin (Nick) always passes.

Deployed to prod 2026-09-16 alongside wiping the 12 existing feed docs (backed up locally).

#113
lfd: Analytics and Team tabs describe tickets filed in the last 15 days only
Joe ChiusanoLegal Front Dooropened 2026-09-16merged 2026-09-163 files, +55 / −15
Direction change from Joe tonight, replacing #112 (closed): the 95 imported Cases stay in; the Legal Front Door Analytics and Team (Workload) tabs stop counting anything filed more than 15 days ago.
Merged

Direction change from Joe tonight, replacing #112 (closed): the 95 imported Cases stay in; the Legal Front Door Analytics and Team (Workload) tabs stop counting anything filed more than 15 days ago.

Why a filing-date window, not an "imported" flag: the import carries the Cases' real created dates, so the moment it landed Workload read Elisa · 57 open · 57 awaiting reply · oldest 503d. A window on createdAtMs takes those out, takes out a live-but-stale ticket that would do the same, and needs no per-ticket marker.

What changes

  • lfdMetrics.ts: ANALYTICS_WINDOW_DAYS = 15 + inAnalyticsWindow(r, now), one place.
  • LfdAnalytics.tsx / LfdTeam.tsx: filter applied once, right after useIntakeRequests(). Labels now say what they measure ("last 15 days" on categories, requesters, closed, nudges, SLA compliance; Team subtitle; a caption "*N of M in the snapshot*"). Volume chart shows 4 weeks instead of 12 mostly-empty ones.
  • Queue tab is not windowed — every ticket stays listed, ages, and turns red there. CSV export still carries every ticket.

Cost, stated plainly: a live ticket older than 15 days also drops out of these two tabs. If that's wrong for the pilot, the constant is the knob.

Web tsc clean. Hosting deploy.

#112
lfd backfill: rollback --only <CaseNumbers> — remove part of a batch without touching the rest
Joe ChiusanoLegal Front Dooropened 2026-09-16closed 2026-09-161 file, +17 / −3
Tonight's load (20:02 ET) went in exactly as designed on the notification side — 95 × recorded silently (backfill), 188 bells held, 0 DM attempts, poller changed: 0 then Chatter filled in — but the population was too wide: the manifest had no created-date cutoff. That was my scoping, not the runner's. Agreed window…
Closed

Tonight's load (20:02 ET) went in exactly as designed on the notification side — 95 × recorded silently (backfill), 188 bells held, 0 DM attempts, poller changed: 0 then Chatter filled in — but the population was too wide: the manifest had no created-date cutoff. That was my scoping, not the runner's. Agreed window (Joe, tonight): Cases created in the last 90 days. That keeps 57 of the loaded 95 and removes 38 (Elisa 34, Amy 2, Sasha 1, Atu 1; oldest April 2025 — the 503-day row on Elisa's Workload line).

This adds rollback --only <CaseNumbers> so those 38 come out without touching the other 57. Every named Case must be in the batch or nothing is deleted; the worked-on refusal and --force are unchanged. Script only — nothing to deploy; it runs from this branch.

node scripts/backfill-legal-cases.mjs rollback --project legal-dashboard-ee977 --batch-id <tonight's batch id> \
  --only 00018700,00020022,00020308,00020550,00020552,00021267,00021376,00021571,00021703,00021906,00021938,00022051,00022155,00022163,00022234,00022253,00022256,00022257,00022501,00022513,00022552,00022619,00022654,00022863,00022962,00023150,00023157,00023165,00023299,00023300,00023306,00023339,00023644,00023871,00023886,00023952,00023995,00024018

Dry run first; add --apply to delete. Expect 38 of the batch's 95 ticket(s) selected. The list is loaded 95 − Salesforce CreatedDate = LAST_N_DAYS:90, computed against the live org, so the boundary is Salesforce's.

Runbook follow-up (next PR): --created-since / --created-within-days becomes required on export so a load can never go out without an explicit date window.

#111
lfd: import the open Salesforce Case backlog as tickets, without waking anyone
Joe ChiusanoLegal Front Dooropened 2026-09-15merged 2026-09-1515 files, +1817 / −12
Nick (2026-09-15, call): bring the open Salesforce Case backlog into Winston, without triggering notifications. Scope agreed the same day: Cases owned by the legal roster — 95 open in production when measured (Elisa 57 · Amy 11 · Sasha 8 · Tara 7 · Brijesh 5 · Flor 4 · Atu 1 · Ari 1 · Sarah 1; Isha 0). Runbook:…
Merged

Nick (2026-09-15, call): bring the open Salesforce Case backlog into Winston, without triggering notifications. Scope agreed the same day: Cases owned by the legal roster — 95 open in production when measured (Elisa 57 · Amy 11 · Sasha 8 · Tara 7 · Brijesh 5 · Flor 4 · Atu 1 · Ari 1 · Sarah 1; Isha 0). Runbook: docs/lfd-backfill-runbook.md.

What this adds

  • functions/src/lfd-backfill.ts — the document a Case becomes: recordIntake's shape, source: "salesforce", the Case's real CreatedDate as createdAtMs, owner pre-resolved through the roster (sfUserId, then name) so the mirror lands in its no-op branch, status assigned/new, acknowledgedAtMs = CreatedDate for owned Cases, the poller's sf* fields pre-stamped so its first pass changes nothing, and a backfilled: true marker. Plus the SOQL builder, the plan (skips Winston-created and already-imported Cases on either id spelling), the switch preflight, the summary.
  • functions/scripts/backfill-legal-cases.mjs — export (Salesforce → reviewable manifest; --owners roster, --owner-names, --created-since, --modified-within-days), import (dry run by default; refuses unless sfMirrorSilent=true and lawyerNotifications.slack/inApp=false; create() on deterministic ids; reads its writes back; --limit 1 canary), rollback (one batch, nothing else).
  • lfd-health.ts — imported tickets are split out before checks 2–4 and reported once at info level (importedBacklog). Without this the 15-minute health check pages on "assigned, no first response" and "owner not on roster" the moment the import lands.
  • lfd-digest.ts — same marker: one count line, not over-SLA / unassigned / per-owner lines; 95 imported open is not a "quiet week".
  • UI: salesforce source chip and label; IntakeRequest carries the marker.

Every emitter, and what holds it

  • Would fire — Held by
  • bell (lfdNotifications) — lawyerNotifications.inApp=false (#99) — held back, not queued
  • assignee DM / requester thread / card (lfdSfMirror) — sfMirrorSilent=true (#69), no Slack thread on the doc, owner pre-resolved
  • health alarms + daily digest DM — backfilled exemption for the four ownership/SLA checks — this PR. The other checks are unchanged and still alert on real faults
  • Monday digest — backfilled exemption — this PR. The Monday post itself still goes out (it is the existing weekly summary); imports are one count line in it
  • poller alarm — does not fire on a clean import: owner/status pre-stamped ⇒ changed: 0; Chatter on these Cases is far under the 2,000-row cap. Still alarms on a real Salesforce failure, as designed
  • Salesforce — the import, mirror trigger and poller write nothing. A lawyer acting on an imported ticket in Winston does (Reassign / Close / Reopen write the production Case) — Winston's normal behaviour for a production Case; flagged so it is a decision

Codex round 1 (16 findings) — fixed in the third commit

Org↔project guard by Organization Id (no override); --limit/--only/owner-selector validation; one batch id across canary + rest with read-back accounting; rollback refuses worked-on tickets without --force; strict --owners roster; dry run reports how many imports fall outside the UI's newest-200 read; mirror silent branch no longer re-stamps triagedAt on a ticket that already holds the owner; owner-history entry labelled "owner at import"; analytics source chart includes Salesforce. Runbook claims that were too strong are corrected (digest still posts; exemption is ownership/SLA only; poller alarm wording; requester = Case creator; acknowledged-at-creation is an assumption; the reply composer records but cannot deliver on these tickets; the UI does not read the marker). Not changed, for Nick to rule on: lawyer actions on imported tickets writing the production Case, and whether the UI should read the marker (age chips, analytics, Team unassigned bucket for the 7 unmapped owners).

For Nick

  1. Deploy the five functions above from main.
  2. Flip the three switches in config/legalTriage.
  3. Run the import (the manifest is exported and reviewed; my ADC cannot write prod Firestore — PERMISSION_DENIED — so the write is yours unless you grant roles/datastore.user on legal-dashboard-ee977).
  4. Watch one poller run and one health run, flip the switches back.

Population is a flag away if you want it wider: all 288 open non-Winston Cases (104 owned by inactive users, 90 of them Chandler's) or --created-since 2026-01-01 (127). At 95 the queue's 200-doc read and the poller's 400 cap have room; at 288 the UI cap would need raising.

Test and deploy notes (Verified) are on GitHub · Open on GitHub ↗
#110
lfd: counterparty NDA / DPA / ESA uploads are filed into Ironclad as the workflow draft
Nick RaffeyLegal Front Dooropened 2026-09-15merged 2026-09-157 files, +987 / −25
A request on the counterparty's paper (NDA / DPA / ESA) with the document attached now also launches the matching Ironclad workflow with that upload as the draft, on paperSource: "Counterparty paper". Legal reviews it in Ironclad instead of re-keying the launch form from the Case attachment.
Merged

What

A request on the counterparty's paper (NDA / DPA / ESA) with the document attached now also launches the matching Ironclad workflow with that upload as the draft, on paperSource: "Counterparty paper". Legal reviews it in Ironclad instead of re-keying the launch form from the Case attachment.

  • Ironclad (ironclad-launch.ts): multipart launch (data JSON part + one part per file, [{file: part}] on draft; ESA extras on localAdditionalDocumentation). .doc/.docx/.pdf only on draft — other formats fail at Sign, so they stay on the Case. Per-template attribute sets mirror the manual launch form's always-required fields (schemas read live 2026-09-15). paperUploadBlocked fails closed with a named reason.
  • Slack page 2 (their paper, lane on): file input required and must include a .docx/.pdf; DPA/ESA show the legal-name / signer / (ESA) contact boxes with their-paper copy and the same Salesforce pre-fill; NDA shows the send-now blocks + "mutual or one-way". No send-now, nothing generated.
  • Filer: Case + attachments first (unchanged), then the same bytes go to Ironclad. Ticket gets ironcladWorkflowId/Url + paperUpload* fields (not ndaOutcome/docOutcome, so the pollers ignore it). Not self-serve — routing/assignment unchanged. Thread post, triage FYI and a private CaseComment name the workflow; skip/failure reasons land in all three places.
  • Web: ticket pane shows the Ironclad line (or the skip/error reason).
  • Gate: PAPER_UPLOAD_ENABLED=on (default off ⇒ zero behaviour change). Added to functions/.env locally.

Verify

  • functions: npm run build && node scripts/intake-launch-harness.mjs → 92 checks pass (6 new: gate, page-2 blocks, validation, paperUploadBlocked, payloads per template, multipart envelope).
  • Web: tsc --noEmit + eslint clean.
  • Not yet done — first live run: internal test Account, throwaway .docx, workflow open in Ironclad. Confirm it sits at Create on Counterparty paper with the draft attached and no signature step fires. The schema does not say what draft's conditional requirement hinges on, so this run is also what proves the "always"-required set holds on their paper; a 400 names the field on the triage FYI.

Docs: docs/ironclad-launch-prereqs.md → "Counterparty paper → Ironclad (2026-09-15)".

#109
rules: Agents data readable via the agents module (Joe as second operator)
Nick RaffeyPlatformopened 2026-09-15merged 2026-09-151 file, +5 / −3
Joe gets the Agents page (module granted in Firestore). The three agents collections were read-pinned to the admin email; now hasModule('agents') && isLegalRole(). Writes unchanged (false). Deploy: firebase deploy --only firestore:rules.
Merged

Joe gets the Agents page (module granted in Firestore). The three agents collections were read-pinned to the admin email; now hasModule('agents') && isLegalRole(). Writes unchanged (false). Deploy: firebase deploy --only firestore:rules.

#108
lfd: a scheduled Legal Front Door health check (invariants + credential probes)
Nick RaffeyLegal Front Dooropened 2026-09-15merged 2026-09-159 files, +2393 / −41
Nick, the day after the production cutover: "a health agent that runs every once in a while and checks everything is working."
Merged

Nick, the day after the production cutover: "a health agent that runs every once in a while and checks everything is working."

This is that. It is read-only: no ticket, no Salesforce Case, no Ironclad workflow, no Slack post to any requester or lawyer. Its only writes are config/lfdHealth and its own agentRuns record. Alerts go to the operators via Winston – Notify.

Why invariants rather than a synthetic filer

Every failure this system has actually had was a state that should have been impossible and sat there unnoticed: an owner read-back flag that never cleared (holding the triagers' bell), unread bells pointing at closed tickets, a poller holding a valid token and 403ing on every call, a roster row with no sfUserId so its owner could never be DM'd. A canary ticket passes straight through all of them — and files real Cases to do it. The live queue is the fixture; these are the assertions.

The checks

  • # — Invariant (key) — Severity — Threshold
  • 1 — ownerReadBackStuck — alarm — open + sfOwnerPending for > 10 min
  • 2a — ownerNotOnRoster — alarm — open sfOwnerId matching no roster sfUserId
  • 2b — assigneeDmMissing — warn — ownerSlackId set, no assigneeDmChannel > 15 min after ack
  • 3 — unassignedTooLong — warn → alarm — no owner > 24h → past the bucket SLA target
  • 4 — firstResponseOverdue — warn → alarm — assigned/in-progress, no assignee message > 48h since ack → past target
  • 5 — staleBells — alarm — unread lfdRequest bell whose ticket is closed/missing
  • 6 — orphanedAnchors — info — lfdThreadIndex row > 24h old pointing at a missing ticket
  • 7 — rosterIntegrity — warn — roster missing sfUserId/slackId/email; roster+triager emails with no users doc or no legalFrontDoor module; triagers off the roster; dropped rows; empty roster
  • 8 — probe.* ×4 — alarm — Slack auth.test (both tokens), Salesforce token + SELECT Id FROM Case LIMIT 1, Ironclad token + GET /workflow-schemas
  • 9 — pollerFreshness — info — reuses monitor.ts's own LFD rows
  • 10 — recentFilingErrors — warn — > 3 lfdErrors in 24h, with the top 3 messages

Plus two self-checks: openQueueRead (alarm) and openQueueTruncated (warn) — a cap that was hit is reported, never silently truncated.

Every threshold is overridable in config/lfdHealth.thresholds without a deploy; only finite positive numbers survive the read.

Schedule and alert policy

  • lfdHealthCheck — */15, invariants only, binds the two Slack tokens and nothing else.
  • lfdHealthProbes — hourly, the only one holding the Salesforce/Ironclad secrets.
  • lfdHealthDigest — 0 8 * America/Los_Angeles, invariants + the one daily warning DM.

alarm → DM now, once, then ≤ every 6h while it lasts, then a recovery note (monitor.ts's raise() shape). warn → never pages; one 08:00 PT digest. info → recorded only. ok → silent. State is written before the DMs so a Slack outage can't replay every alarm next run.

Assumptions, and what I verified rather than assumed

  • SLA provenance. config/legalTriage.slaTargets is time-to-close in days and is the only stored SLA in the repo — there is no stored ack or first-response target. So I did not invent one: the warn thresholds are explicit tunable hour counts described as such in every message, and the alarm threshold is the ticket's real bucket target via lfd-digest's own slaBucket + targetFor (exported for this; previously private). ⚠️ Consequence worth reviewing: for a bucket whose target is shorter than the warn threshold (nda at 2 days vs. 48h) a breach goes straight to alarm with no warn window. I believe that ordering is right — the stored target should win over a generic threshold — but it's a deliberate choice, and the harness pins it.
  • One fault, one pager. Check 9 is info and never DMs: monitorSync already alerts on those rows. To stop the two drifting I extracted CHECKS/evaluateChecks/docReader/openTicketCounter/lfdPollerFreshness in monitor.ts and rewired runSyncMonitor onto them — behaviour-preserving, verified by its existing 13-check harness still passing untouched.
  • Probes prove more than a token fetch. A revoked scope leaves a valid token — that's the real 2026-09-09 failure (signed-NDA poller, 403 on every call for weeks). So Ironclad does token + an authenticated GET, and a read that sees zero launch schemas is reported as failed. Salesforce goes through lfdCreds()/probeSalesforce, inheriting sf-demo's login gate unchanged: off ⇒ no probe at all, sandbox ⇒ refuses a non-sandbox, production ⇒ refuses anything but the BetterUp production org id. It probes only what LFD currently targets and adds a reader, not a capability — the sandbox/prod isolation rule holds.
  • Credentials never surface. Probes are injected closures; failures report the error class + the API's own message (truncated). A harness check asserts no token appears in the run result.
  • No PII in output. Health output goes to DMs and Cloud Logging, which outlive the ticket — messages carry ids only. A harness check plants a subject/description/requester name/email and asserts none reach the message or the DM.
  • slackAlerts is probed only when SLACK_ALERTS_BOT_TOKEN is its own secret; alertsToken() falls back to the Winston token, and probing that twice would show a green row for a credential that doesn't exist.
  • A probe failure recorded on the hourly run keeps the 15-minute runs red, so the board doesn't flicker green over a dead credential.
  • A check that throws is a failure, never a pass — a green board over a broken checker is the worst outcome available. Pinned.

Known regression classes, checked

  • PT vs UTC bucketing. The digest's once-a-day guard is a PT calendar-day key (Intl, America/Los_Angeles), and the cron uses timeZone, not a UTC hour. A UTC-derived key would allow two sends on one PT day — the same bug that cost the forecast snapshot Jul 4/5/8. Pinned with cases either side of the DST boundary.
  • Cap-then-filter. Every predicate that narrows the set is in the query (status in, sfOwnerPending ==, atMs >=, createdAtMs <); only per-ticket properties are filtered in memory. count() is used where a number is enough (error counts, the open-queue count) and the error bodies are only read once the count has already breached.
  • Harnesses encoding impossible states. Fixtures follow the real create → stamp → mirror sequence (ownerSlackId: null at create, sfOwnerId merged one write later). One test specifically pins that an 18-char sfOwnerId matches a 15-char roster sfUserId — a raw string compare there would page someone over a perfectly reachable owner. Legacy "filed" tickets are pinned as open.

Indexes, secrets, env

  • No new Firestore composite indexes, and none added to firestore.indexes.json. Every query is single-field (status in, sfOwnerPending ==, atMs >=, createdAtMs <, email ==) or an equality-only conjunction (notifications: targetType + read), which Firestore serves by merging single-field indexes — the same read lfd-notify.clearTicketBells already does in production. firestore.indexes.json has no fieldOverrides disabling indexing on any field used here. Given this repo's history with the index deploy gap, I'd rather say this explicitly than have it discovered.
  • No new secrets and no new env vars. Reuses SLACK_LFD_BOT_TOKEN, SLACK_ALERTS_BOT_TOKEN, SF_LFD_*/SF_DEMO_* (via LFD_TARGET_ORG), IRONCLAD_LAUNCH_*, SYNC_ALERT_EMAILS, DASHBOARD_URL.
  • No UI change. AgentsView renders the registry, so the "LFD Health" card appears on the first run. Verdicts: alarm → fail, warn → uncertain (not pass), ok → pass.

Found, not fixed

  • checkStaleBells and checkOrphanAnchors do bounded sequential point-gets (≤ 60 unique tickets each). Fine at ~44 open tickets and a 15-minute cadence; getAll() would be the move if the queue grows an order of magnitude.
  • If an integration's secrets are removed from the deployment, its probe.* alert state stays firing: true (it sends nothing, but a later re-configure would emit one spurious "recovered"). Left alone as harmless rather than adding state-reaping complexity.
  • Check 6 deviates slightly from a literal reading of the brief: an anchor on a closed ticket is normal and permanent (closed tickets keep their threads for late replies), so it's counted as context and only a missing ticket marks the check failed. Otherwise the check would "fail" once per closed ticket, forever.
  • loadTriageConfig is memoised for 60s, so the roster check can lag a roster edit by up to a minute. Not worth bypassing; the harness clears the cache between fixtures via the existing clearTriageConfigCache() export.
Test and deploy notes (Verification) are on GitHub · Open on GitHub ↗
#107
lfd: lawyers can answer a ticket from inside Winston
Nick RaffeyLegal Front Dooropened 2026-09-15merged 2026-09-157 files, +962 / −9
A lawyer can now answer a Legal Front Door ticket from inside Winston. Before this, the only reply path was their Slack DM thread and the ticket pane could only deep-link into it — so a ticket with no assignee DM anchor (owner not on the roster, lawyer Slack notifications off when it was assigned, or…
Merged

What

A lawyer can now answer a Legal Front Door ticket from inside Winston. Before this, the only reply path was their Slack DM thread and the ticket pane could only deep-link into it — so a ticket with no assignee DM anchor (owner not on the roster, lawyer Slack notifications off when it was assigned, or conversations.open failed) had no answer path in Winston at all. That case is most of the point: handleSfMirrorChange really does write name-only owners (ownerName set, no ownerSlackId, no DM), and those lawyers could not reply anywhere but Salesforce.

New reply action on the existing lfdTriageAssign endpoint → replyFromWinston in functions/src/lfd-triage.ts:

  1. posts into the requester's receipt thread (slackChannel + slackThreadTs — the same anchor relayTicketMessage targets from the assignee side), as 💬 Sasha Nichols (legal, via Winston): …;
  2. posts the identical line into assigneeDmChannel + assigneeDmThreadTs when they exist, so the lawyer's Slack history stays coherent and later requester replies still relay into a thread showing both halves;
  3. appends one messages[] entry in the relay's shape plus via: "winston";
  4. bumps assigned → in_progress via setRequestStatus when (and only when) the requester post landed — the same condition the Slack relay uses.

Frontend: one shared ReplyComposer mounted in both panes (TicketDetailPane for Split view, TicketModal for the queue's modal and the Deal Desk drawer), textarea + Send, ⌘/Ctrl+Enter, optimistic append that drops itself once the live snapshot carries the message, disabled-with-reason when closed or not allowed. Timeline entries show · via Winston, and an undelivered entry shows logged, not delivered. "Reply in Slack" stays, demoted to secondary.

Exact Firestore fields written

One update() on legalIntakeRequests/{id}, touching messages only:

messages: FieldValue.arrayUnion({
  atMs, at, from: "assignee", actorSlackId, actorName,
  text (trimmed, ≤2000), delivered, via: "winston",
  // only when delivered === false:
  deliveryFailed: true, deliveryNote?
})

Then, only on a delivered reply to an assigned ticket, setRequestStatus(… "in_progress") — the existing shared path, unchanged.

Response-time stamps. I read the Slack relay (relayTicketMessage, ~line 4914) fully: its only doc write is that same messages arrayUnion, plus the status bump. The response-time numbers are derived, not stored — lfdMetrics.timeToFirstLegalReplyMs / firstResponseMs / awaitingLegalReply / longestReplyGapMs all read messages[], and LfdAnalytics / LfdTeam / lfd-digest use the same accessors. So a from: "assignee" entry is the stamp; the harness recomputes lfdMetrics' own arithmetic on the written doc to pin that. I deliberately did not write acknowledgedAtMs (assignment, first-write-wins), completedAtMs, or sfFirstResponseAt (SF-mirror-owned, Winston never writes it) — the Slack path writes none of them on a reply, and inventing a field here would make a web reply and a Slack reply measure differently.

Authorization

Two gates, both server-side:

  • unchanged endpoint gate — Firebase ID token via verifyBetterUpUser, then getUserAccess(decoded) must be legal or admin;
  • new reply gate — replyAuthz: access.role === "admin", or the caller's email on config/legalTriage.triagers, or the ticket's owner, matched by the caller's roster entry against ownerSlackId (and against ownerName only when the ticket has no Slack-mapped owner — the name-only SF owner case). Email comparison is trimmed/lowercased. A failed/absent config read fails closed for everyone but an admin, the opposite direction from readLawyerNotifications, because this is authorization and not a notification preference.

Attribution comes from the verified token + roster, never from the request body: the client cannot choose whose name the requester sees. Empty/whitespace → 400, over 2000 chars → 400 (both enforced in the function; index.ts also shape-checks), closed ticket → 409, unknown id → 404. Text is escaped for Slack mrkdwn (&, <, >) so <!channel> / <@U…> cannot ping from a lawyer's reply; the ticket stores what was typed. firestore.rules untouched — legalIntakeRequests stays allow write: if false, so the client cannot write messages.

The lawyer-notification switch

config/legalTriage.lawyerNotifications is not consulted. Confirmed by reading lfd-notify-switch.ts and every lawyerNotificationsMuted call site: it governs sends to lawyers (assignee DM on assignment, handoff transcript, nudge DM, the in-app bell), and its own header excludes the requester's thread, the triage card and Salesforce. A lawyer's reply to a requester goes out regardless. The assignee DM copy is also not gated — matching relayTicketMessage, which posts requester messages into that DM without consulting the switch.

Known regression classes, checked

  • "merged one write too late" / accidental bells. lfd-notify reacts to every write of the collection. Its arrival branch needs before.sfOwnerPending && !after.sfOwnerPending; its assigned branch needs ownerSlackId to change. A reply writes neither, and never status: "closed", so no bell rings and nothing reads as a reassign or a close. The harness asserts the write surface, not just the end state: the only update is {fields: ["messages"]}, and ownerSlackId / ownerName / sfOwnerPending / completedAtMs / triagedBy are never among the fields written by the whole call.
  • Delivering before stamping the outcome. The post must happen before the record, because the record's delivered flag is the outcome (same order as the relay). What's guarded is the claim: nothing says "sent" unless the requester post returned ok. If the Firestore write then fails, the call returns ok: false — and when the message did reach Slack the copy says "don't resend it; it's in their thread" rather than "try again".
  • Harness states production can't produce. Fixtures are what assignRequest leaves behind (both anchors) and what handleSfMirrorChange's name-only branch leaves behind (ownerName, no ownerSlackId, no DM anchor — verified at lfd-triage.ts ~1455).

Assumptions and what I did not do

  • Attachments. Text only. The relay can carry files; the composer can't. Not in scope, and a file needs an upload path this endpoint doesn't have.
  • "Bottom of the pane" = under the Conversation, directly above the triage controls, so the composer sits with the timeline it appends to rather than below the Chatter section.
  • Optimistic entries dedupe on exact text. Sending the identical string twice in one session shows one pending row until the snapshot lands. Chose that over an id round-trip; the record is correct either way.
  • The disabled composer renders for a closed ticket (with the reason) rather than disappearing — that's what the brief asked for, but it does mean a closed ticket in the Deal Desk drawer shows a greyed box.
  • TicketModal and TicketDetailPane are still two hand-maintained panes (TicketModal is not a wrapper around the pane, despite the pane's docstring saying it "replaces" it). I shared the composer rather than merging them; the via Winston / logged, not delivered markers had to be added in both message lists.
  • No rules change, no index change, no new function — the reply rides lfdTriageAssign, which already binds the Slack bot token.
Test and deploy notes (Verification) are on GitHub · Open on GitHub ↗
#106
docs: routing matrix — product legal → Elisa Cooke (Karen Lawson left)
Nick RaffeyLegal Front Dooropened 2026-09-15merged 2026-09-151 file, +1 / −1
Karen Lawson left BetterUp 2026-09-15. Product legal / coaching data / member data questions now route to Elisa Cooke (Nick). Docs only.
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
Two silent failures from the 2026-09-14 audit, both in functions/ only. Nothing deployed.
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 — they skip the read-back and stay open), or on the write where the flag clears with no owner attached (!ownerSlackId && !sfOwnerId): Salesforce named a queue, named nobody, or the read failed.
  • stampSfOwnerAtFiling now clears sfOwnerPending on every exit path — in the same merge as the owner when there is one, and in a trailing unconditional merge otherwise (previously that trailing write only happened when extra was non-empty, so a thrown read-back would have left the flag standing and the ticket silent). An unowned ticket still rings.
  • The owner's own "assigned to you" bell is untouched; nda-template still rings nobody; the lfd-${id}-new-${uid} ids are unchanged, so createOnce still makes a redelivery — or any path that somehow hit both branches — a single ring.

Checked, per the ground rules:

  • "a field merged one write too late": that is precisely this bug, and the fix is verified against the real three-write sequence rather than a constructed state. Nothing between recordIntake and stampSfOwnerAtFiling can throw and strand the flag: the betaOutcome write is .catched and postTriageCard swallows everything internally.
  • "a harness encoding a state production can't produce": notify-harness.cjs's owned-arrival fixture was exactly that (before=undefined with an ownerSlackId), which is why it passed against an inert guard. Rewritten to drive create → stamp → mirror.
  • Other writers of ownerSlackId: case-owner-poll.ts writes only sf* fields, never ownerSlackId, so it cannot change the analysis; lfdSfMirror and lfd-triage.assignRequest are the only writers, always after a create.

Fix 2 — the LFD pollers could die silently

poller-health.ts has stamped config/pollerHealth since 09-09 and nothing read it: a poller that fails on every ticket shouts, but one that stops running (schedule dropped, function deleted, deploy that lost it) said nothing, and the symptom was tickets quietly not moving.

monitor.ts gains LFD rows in the existing CHECKS table, alerting through the existing staleLines → sendSlackDM path (Winston – Notify, SYNC_ALERT_EMAILS; no new channel). Check grows three optional fields (key for dedup — every LFD row reads the same document, so the old doc.split("/")[1] key would collide; dotted field; needsOpenTickets), ageMs learns epoch-ms, and config/pollerHealth is read once per run.

Two fields, because they answer different questions:

  • row — field — threshold
  • LFD case-owner poll (running) — lfdCaseOwnerPoll.lastRunAtMs — 10m (3× 2m + slack)
  • LFD case-owner poll (checking tickets) — lfdCaseOwnerPoll.lastHealthyAtMs — 10m, only while the queue is non-empty
  • LFD Chatter mirror (checking tickets) — lfdCaseChatter.lastHealthyAtMs — 10m, same gate
  • LFD doc-ready poll (running) — lfdDocReadyPoll.lastRunAtMs — 10m
  • LFD signed-NDA poll (running) — lfdNdaSignedPoll.lastRunAtMs — 50m (3× 15m + slack)
  • LFD beta-terms poll (running) — lfdBetaTermsPoll.lastRunAtMs — 50m

lastHealthyAtMs is only stamped by a run that actually checked something. That is honest for the owner/Chatter feeds (they read every OPEN ticket), but the three self-serve pollers select tickets awaiting an outcome (ndaOutcome/betaOutcome/docOutcome === "pending"), which is legitimately empty most of the day — alarming on their healthy stamp would page someone every quiet afternoon, so they are held to lastRunAtMs instead. An absent stamp is Infinity, i.e. an alarm: a row that was never written has never been healthy.

Plus the zero-eligible alarm: the last owner-poll run checked 0 while the open legalIntakeRequests set is non-empty, and the run did not explain itself with skippedOtherOrg (that case is already alarmed by reportPollerHealth's otherOrgOnly branch, in its own words — two systems reporting one fault is how people learn to skim). Same dedup/cooldown/recovery bookkeeping as every other row.

On the run.checked > 0 gate: yes, PR #96 already fixed it — git log -S "noSignal" points at 790fa27 (#96), which added noSignal / otherOrgOnly so a zero-checked run no longer stamps lastHealthyAtMs. What #96 left open is the silent variant: a zero-checked run with nothing to explain it, which stamped nothing and told nobody. That is what these rows close.

Harnesses — all green (npm run build clean first)

  • harness — result
  • notify-harness.cjs — 19 passed (was 16; rewrote the impossible fixture, added 4)
  • monitor-harness.cjs (new) — 13 passed
  • poller-health-harness.cjs — 11 passed
  • lawyer-notify-switch-harness.cjs — 14 passed
  • intake-launch-harness.mjs — 86 passed
  • reopen-review-harness.mjs — 16 passed

The new notify checks were verified to fail against the old code (stashed lfd-notify.ts, rebuilt, re-ran): an auto-assigned filing: create rings nobody… and Salesforce assigned NOBODY: the stamp clearing rings every triager both FAIL, the other 17 pass. monitor-harness.cjs cannot run against the old code at all — neither the rows nor the injection point existed.

Found, not fixed

  • Name-only SF owners now get no in-app bell at all. When Salesforce assigns someone who is not on config/legalTriage.roster, lfd-triage records a name-only assignment and ownerSlackId stays null, so there is no owner bell — and because sfOwnerId is set, the triagers correctly don't ring either. Previously the triagers rang (by accident, on the create). The Slack triage card is still updated and the requester is still told, so the ticket is not invisible, but nobody gets a bell. The real fix is adding those people to the roster with their sfUserId; a deliberate "assigned to someone Winston doesn't know" bell would be a product call.
  • A failed nda-template handout can still end up silent: that lane clears selfServe and stamps the owner, and if Salesforce names nobody, no bell rings for anyone (pre-existing; the flag was never set on that path, so this PR neither helps nor hurts it).
  • The three 15-minute pollers early-return before reportPollerHealth when their credentials are absent (if (!d.sfCreds.clientId) return, if (!d.ironclad) return), so a deployment deliberately running without Ironclad or Salesforce creds will trip the new "(running)" rows. Arguably correct — a poller that never runs is not watching anything — but it is a behaviour worth knowing before this reaches staging.
  • index.ts's agent-run evidence label was hardcoded to syncMeta/${key}; now uses the row's real doc (one line, needed for the new rows to read sensibly).

Fix 3 — closing a ticket clears its bells (added by Fable after review)

Live 2026-09-15: Joe's self-serve NDA (yrtpv3hjTtSDPmjDrGiZ) rang the triagers on arrival — correct for the ironclad-nda lane — then auto-closed on signature, and Nick's sidebar kept showing "1" for a ticket the Open queue doesn't list. lfd-notify now sweeps on the transition into closed: every unread lfdRequest notification for that ticket gets read: true (same field markNotificationRead sets). Equality-only query, no index; best-effort; runs ahead of the console-switch gate so muting new bells never strands old ones. notify-harness 22/22 (three new cases: the sweep hits only that ticket's unread bells; no bells / closed→closed is a no-op; the sweep runs while inApp is off).

Reviewed the agent's two fixes: the sfOwnerPending create-time marker is the right shape (a marker merged later is the exact "one write too late" bug), the stamp clears it on every exit, and the monitor rows reuse the existing alert path. Nothing deployed.

#104
chore: ignore .env.bak* copies; matrix note — Order Form routes to Tara
Nick RaffeyPlatformopened 2026-09-15merged 2026-09-152 files, +5 / −2
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.
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
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…
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
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.
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.

Follow-ups

  • Joe / Salesforce: the MSA Agreement_Type__c value and the MSA-review → Sasha assignment rule (09-10) will no longer receive anything from Winston.
  • Deploy: the six functions that import lfd-intake + hosting (from the repo root, not a worktree).
Test and deploy notes (Verification) are on GitHub · Open on GitHub ↗
#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
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.
Merged

What

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.

Changes

Being on the roster is being allowlisted. The routing matrix already assigns these people work and Winston already DMs them about it. Requiring a second, hand-maintained env var only created two lists that could disagree. Adding someone to the roster is now sufficient — no deploy, same shape as the lawyerNotifications switch.

An anchored thread reply passes the gate. A message in a thread Winston created is conversation on a record we own. It can't be used to get in:

  • the anchor is per-thread and only Winston writes one (intake-record.ts / lfd-question.ts),
  • an outsider cold-messaging Winston has no anchor, so the door gate still stops them,
  • a second gate stops anything that passed the first without relaying, so the bypass is a licence to be relayed and nothing more.

Both reads fail closed. An unreadable roster falls back to the static list (it can only widen, never narrow); an unreadable thread index is not a permit. The env var still works and is still the floor.

Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#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
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…
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

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 lookups are only unambiguous, with one account per email.
  • One strict validator on the final list (roster or --emails): trimmed, lowercased, @betterup.co only, deduped. Skips are listed, never silent; exit 2 when anyone was skipped.
  • Read-back after --apply: every non-skipped person must have an allowed role AND the stored module, and an enabled, email-verified account (rules and middleware refuse otherwise).
  • Never removes a module, never lowers a role, never touches profile fields.
  • --project is gated to legal-dashboard-ee977 / legal-dashboard-staging; use --emails on staging.

Runbook: docs/lfd-lawyer-access-runbook.md — including that plain user ADC is rejected by the Firebase Auth Admin API, so run it with a service account (impersonation or key) holding Firebase Authentication Admin + Cloud Datastore User.

Prerequisite: roster[].email filled for all eight lawyers in config/legalTriage. Entries without one are listed and skipped.

Not in scope

  • Web filing stays with Nick and Joe (SF_DEMO_OPERATORS); lawyers get the queue, the ticket, Chatter and the deal drawer.
  • firestore.rules hasModule() and related-chatter.ts bypass the module check for the pinned operator only, while resolveAccess implies every module for any admin role. The script sidesteps it by storing the module for admins; aligning the rules on isRoleAdmin() is a separate change.
Test and deploy notes (Verification) are on GitHub · Open on GitHub ↗
#99
lfd: lawyer notifications are a console switch (config/legalTriage.lawyerNotifications)
Joe ChiusanoLegal Front Dooropened 2026-09-15merged 2026-09-157 files, +589 / −56
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…
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:

{ "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 (before, a stale anchor was left live in that case).
  • A nudge while Slack is off still counts toward the once-a-day limit and still echoes under the card, but says "logged … notifications are paused" / "not pinged", never "sent".

Go-live sequence

  1. Before the Salesforce backfill: slack: false, inApp: false, sfMirrorSilent: true.
  2. Run the backfill.
  3. sfMirrorSilent: false, then slack: true, inApp: true (or delete the field). Keep muteRequesterSlackIds while Nick and Joe file test tickets.

Docs: docs/lfd-lawyer-notifications.md.

Test and deploy notes (Verification, Deploy) are on GitHub · Open on GitHub ↗
#98
Add guarded Winston sandbox-ticket purge tool
Joe ChiusanoLegal Front Dooropened 2026-09-15closed 2026-09-153 files, +735 / −0
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.
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 are deleted via Slack chat.delete, using SLACK_LFD_BOT_TOKEN, deduplication and 1.25-second spacing (48/min). DM refs are excluded and no messages are posted. Slack failures are logged and do not block the Firestore phase; Retry-After is honored. Once a request is gone, rerunning does not replay its Slack delete: failed cards or a crash between Firestore and Slack need manual reconciliation from export refs.

No Salesforce, Ironclad, Cloud Storage, config, sessions, inbound/event collections or subcollections are modified. This does not reuse the staging recursiveDelete.

Runbook and operational prerequisite

Nick's sequence is cutover/writer quiescence → manifest → reconcile the supplied 62 BU_Full Winston test Cases (48 open), plus questions → dry-run → automatic full export → apply → verify Winston's test queue and legal-triage test cards → repeat apply to verify no-op. Counts are a review baseline, not hard-coded deletion criteria. Ironclad workflows are voided separately by hand and Salesforce Cases remain.

The runbook explicitly requires verified deployed cutover guards. Current main still stamps sandbox:true at intake and does not filter sandbox tickets in every poller; the tool does not claim those cutover changes are already deployed. Intake and remaining writers must be paused/quiescent. Restore guidance notes notification-trigger effects and that Slack deletion is not reversible.

Validation

  • Offline harness: 24/24 passed (fake Firestore, local temporary exports, intercepted fetch; no credentials or live services).
  • Deliberate mutations: 21/21 caught, no survivors:
  1. Manifest phase deletes instead of exiting — caught
  2. Collection allowlist bypassed — caught
  3. Production false guard removed — caught
  4. Apply false guard removed — caught
  5. Legacy missing marker excluded — caught
  6. Question eligibility removed — caught
  7. Timestamp freshness bypassed — caught
  8. Snapshot freshness bypassed — caught
  9. Export omitted — caught
  10. Export failure swallowed — caught
  11. Dry-run bypassed — caught
  12. Duplicate Slack delete allowed — caught
  13. Second apply replays cached Slack card — caught
  14. Slack post substituted — caught
  15. Slack timestamp wrong — caught
  16. DM channel accepted — caught
  17. Rate limit removed — caught
  18. Target guard removed — caught
  19. Batch limit raised — caught
  20. Dependent parent protection removed — caught
  21. Notification targetId support removed — caught
  • tsc --noEmit -p functions/tsconfig.json: clean.
  • node --check functions/scripts/purge-sandbox-tickets.mjs: clean.
  • Functions dependencies installed from the existing npm lockfile; local runtime Node 24.19.0 (functions deployment target is Node 22).
  • No live manifest, export, purge, Salesforce action, Slack message, or Ironclad action was executed.
#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
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.
Merged

What

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.
Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#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
Lets Winston file Salesforce Cases into production with one setting, without touching the deal calculator.
Merged

What

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 org alarms (the silent C1 failure). An idle poller with nothing pending stays quiet.

Production guards

  • Beta Terms: hidden, and no signee set on the Case, unless LFD_BETA_TERMS_AUTOSEND=on. The Salesforce flow BU18_Beta_Terms_Auto_Send sends a DocuSign envelope whenever a Legal Contracting Beta Terms Case has a signee, whatever the paper. The existing comment saying "Their Paper" stops it is wrong against the prod flow.
  • Wording: no "demo sandbox" in the greeting, the request form or the Case description.
  • Web filing: sfDemoProxy createCase returns 409 outside sandbox, so web filing stays on the sandbox until it's cut over separately.
  • Case mirror job: stays sandbox-only and now skips production tickets.

Not in this PR

  • Purge of sandbox test tickets and the backfill importer. Backfill also needs a notification guard: sfMirrorSilent doesn't silence the new-ticket and assignment bells.
  • Web filing to production.
  • A composite index on sandbox + createdAtMs so the production NDA / Beta Terms pollers can order in the query instead of in memory.
  • A watchdog for the scheduled jobs (a paused schedule or dropped function is still silent).
  • Order Form / MSA routing and the retired region values in Salesforce.
Test and deploy notes (Deploy (Nick), Tests) are on GitHub · Open on GitHub ↗
#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
The five follow-ups from today's reviews of #88 / #91 / #94. None were blocking; four were quietly wrong.
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", a contradiction in one sentence and the sort of alert people learn to distrust. Total outages read exactly as before.

4. A comment in sf-demo.ts was actively misleading

It claimed system feed rows are excluded by Type. True of posts; the comment query filters on ParentId alone, so a human reply under a system post does come through and renders with no visible parent. Behaviour left as Joe intended — it's real human text — but the comment now says what the code does.

5. The Chatter tabs promised keyboard behaviour they don't have

role="tab"/"tablist" commits to arrow-key navigation and a tabpanel. There's neither, so a screen reader announces "tab, 1 of 3" and the arrow keys then do nothing — and the <select> #94 replaced was accessible for free.

These are segmented controls (one region whose contents change), so they're now role="group" + aria-pressed toggle buttons: true, natively keyboard-operable, promising nothing it can't keep. Applied to the queue view toggle as well, since that's where the pattern was copied from.

Not done here

lfdNdaSignedPoll, lfdBetaTermsPoll, lfdDocReadyPoll and lfdTriageAssign call intakeDeps() without binding all its secrets, so they log the same warning — but on 15-minute and event-driven schedules, not every 2 minutes. Same one-line fix each; worth a sweep, not worth widening this change.

Test and deploy notes (Verification, Deploy) are on GitHub · Open on GitHub ↗
#94
lfd: Chatter tabs — Case / Account / Opportunity
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-144 files, +28 / −23
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.
Merged

What

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.
Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#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
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.
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.
Test and deploy notes (Verification, Deploy) are on GitHub · Open on GitHub ↗
#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
Follow-up to #88, from reviewing it.
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 is computed from the requested type so a degrade to "Other" can't swallow the explanation.

Deliberately narrow: only the one variant with the precondition, and only when there's no Opportunity. A note on a Case that routes fine is noise that teaches people to skip the Description.

A detail worth knowing

The hint replaces the shared opportunity hint rather than appending to it. pt() clips at 150 characters and that hint is already 81 — my first attempt came back cut off mid-word ("…filed against the accoun…"). There's a regression check for the truncation, not just for the wording.

Open for Joe / legal — Salesforce config, not code

  • Should the account escape hatch be closed for the ESA outright, so it can't be filed unroutable?
  • Do the Government / Financial Institutions / New Business rules have preconditions of their own? I took #88's description at its word that only Non-Government does.
Test and deploy notes (Verification, Deploy) are on GitHub · Open on GitHub ↗
#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
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.
Merged

What

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. setRequestStatus now stamps statusUpdatedAt/statusUpdatedBy only on a real status change, which also keeps time-to-close and the digest accurate.
  • A reopened ticket shows everything again.
  • A note on its own line says "Closed <time> — Chatter shown up to then."
  • The Closed step and all response times are unchanged.

Who can see it (Joe, 2026-09-14): everyone who can see Winston tickets. That's legal and admin users with the Legal Front Door module, mirroring firestore.rules for legalIntakeRequests.

Why live, not stored

An earlier poll-based version was never pushed. It copied both feeds onto the ticket every 2 minutes, and review found four problems with that:

  • A LIMIT shared across tickets could erase other tickets' feeds.
  • Tickets could hit the 1 MiB document limit.
  • Overlapping poll runs could overwrite each other.
  • It sent about 10x the Salesforce traffic.

Reading live, one record per read, avoids all four, and nothing runs unless someone picks a record.

New function: lfdRelatedChatter

POST { requestId, source: "opportunity" | "account" }

  • Access: operator, or a legal/admin role with legalFrontDoor in the stored modules. Anything else fails closed with 403.
  • Parent records: always come from the ticket's Case in Salesforce, never from the browser.
  • Org isolation: same sandbox/prod check as the poll. A ticket from the other org returns 409.
  • Salesforce failure: returns 502, never an empty feed.
  • Salesforce budget:
  • Per user: 12 per minute (relatedChatter).
  • Shared: 120 per minute (relatedChatterAll), charged only right before a Salesforce read and failing closed (503) if Firestore is unavailable.
  • A 30-second per-instance cache, with identical in-flight reads shared.
  • maxInstances: 5.
  • Errors and non-200 responses are never cached.

Shared Chatter read fixes (these also apply to the Case poll from #72)

  • Comments are now their own bounded query (FeedComment WHERE ParentId IN (...)). A new comment on an older post outside the newest-post window used to be lost.
  • QuestionPost is included, with the question title as the text.
  • A read where either query hits its LIMIT reports done: false, so the poll never rewrites or clears a ticket's stored Chatter based on a crowded read. Those tickets count as chatterIncomplete and chatterErrors, so the existing lfdCaseChatter health alarm rings if a chunk stays saturated.
  • Poller health alerts now claim their 6-hour cooldown in a Firestore transaction before sending, so overlapping runs can't both alert. If the claim itself fails, the alert still goes out.

Follow-ups (not in this PR)

These are concurrency gaps in setRequestStatus and the Salesforce mirror that were already there before this change. This PR only stops a sequential repeat close from moving the close time. Fixing them properly means making the close and reopen paths transactional across the Slack card, the queue and the mirror, which deserves its own PR.

  1. Two closes at the same instant: both calls read "open", so both stamp and both notify. The later write sets the close time (and with it the Chatter cutoff), and the requester can get two messages. Fix: claim the transition in a Firestore transaction, and run the side effects only for the call that won.
  2. A sequential repeat close still repeats some side effects: it backfills completedAtMs if missing, may comment on the Case again, and rewrites the card as "Closed by" the second actor. The mirror's caller also posts its requester line on ok: true. Fix: return changed: false before any side effect, and gate caller-owned messages on it.
  3. The mirror's silent close and reopen decide from the trigger snapshot and then write unconditionally. A person doing the same transition in between gets their stamp overwritten. Fix: re-check the current status inside a transaction.
Test and deploy notes (Tests, Deploy) are on GitHub · Open on GitHub ↗
#90
deal desk: Legal Front Door tickets on the deal drawer
Nick RaffeyLegal Front Dooropened 2026-09-14merged 2026-09-143 files, +191 / −1
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…
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
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…
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
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…
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.

Open for Nick

  • Should a Government ESA still generate through Ironclad? Today it does (unchanged).
  • To test the routing itself in BU_Full, the four values need exposing on its Legal Contracting record type.
Test and deploy notes (Verification) are on GitHub · Open on GitHub ↗
#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
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…
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.

Note

The FAQ content itself still needs the updated sheet uploaded to the bucket at faq/Legal Bot Knowledge Base v3.xlsx (exact path — the Drive file is named v2 but the bucket object name is what the code reads).

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
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…
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.
Test and deploy notes (Test, Deploy) are on GitHub · Open on GitHub ↗
#85
LFD DPA: one counterparty email for notices
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-144 files, +102 / −29
Same one-email treatment as the ESA (#83), for the DPA.
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.

After deploy

One Slack DPA test, then check the generated doc: the Email for Notices line shows the counterparty email.

Test and deploy notes (Verification) are on GitHub · Open on GitHub ↗
#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
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.
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.
Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#83
LFD ESA: remove AI questions; one customer email for notices
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-144 files, +177 / −230
Nick, 2026-09-13: remove both AI questions from the ESA Slack form, and ask one email that populates Email for Notices.
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.

After deploy

One Slack ESA test, then check on the generated doc that Email for Notices shows the customer email.

Test and deploy notes (Verification) are on GitHub · Open on GitHub ↗
#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
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:
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.
Test and deploy notes (Test, Deploy + test) are on GitHub · Open on GitHub ↗
#81
lfd: DPA fills the draft's signer name (signerNamePrinted)
Joe ChiusanoLegal Front Dooropened 2026-09-14merged 2026-09-142 files, +23 / −6
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.
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.
Test and deploy notes (Test, Deploy) are on GitHub · Open on GitHub ↗
#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
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…
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.
Test and deploy notes (Test, Deploy) are on GitHub · Open on GitHub ↗
#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
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,…
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 Evergreen" condition.
  • Gate. A new skip reason, no-renewal-type (missing, off-list, or Other without a description), with the same modal, requester, triage and Case wording as the other skips. The term is required unless Evergreen.
  • Payload. renewalType (the schema value), otherRenewalType (Other only), and initialTermLength only when not Evergreen.
  • The self-serve switch is sent as JSON true only when IRONCLAD_ESA_SELF_SERVE_ATTRIBUTE=<key> is set. It's unset by default, because Ironclad rejects an attribute its schema doesn't have ("non-existent attribute(s) specified", the same cause as the DPA outage fixed in #78).
  • Rejection diagnostics (from the Codex review). launchRejectionFields's echo test now compares only sent keys it can see, so the switch and otherRenewalType don't turn an echoed 400 into "every field missing". On an echo it reports any known key the body names that was not sent, which is exactly today's real 400 (param: ["artificialIntelligence"]). Before this, that case reported nothing.
  • Docs. docs/ironclad-launch-prereqs.md now has the diagnosis, the Ironclad steps, the env var and the rollout order, and the earlier sections are corrected (field table, known-unsent list, page-2 blocks including the #77 AI radios, and the fail-closed list).

Rollout

  1. Deploy this PR. Nothing changes for launches while the env var is unset.
  2. Make the Ironclad change and publish.
  3. Read the new question's key from GET /workflow-schemas/67239caaf8b652f37bf7bb8d?form=launch, set IRONCLAD_ESA_SELF_SERVE_ATTRIBUTE in functions/.env, and redeploy functions.
  4. Launch one ESA on a test account and confirm the document shows the effective date and term. Warn the Review approver and cancel the test workflow afterward.
Test and deploy notes (Test, Redeploy) are on GitHub · Open on GitHub ↗
#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
Every DPA filed against an account fails to generate. Prod, 2026-09-13 21:28Z, Case 00024762:
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.

Relation to #77

Independent of #77 and mergeable in either order (#77 touches the ESA branch of attributesFor, not the shared base).

Test and deploy notes (Test, Redeploy) are on GitHub · Open on GitHub ↗
#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
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:
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 Case description.
  • Missing answers. A missing answer fails closed (no-ai-diligence / no-ai-aware) with the same modal, requester, triage and Case wording as the other skips. A page 2 opened before this deploy still submits, and the filer then refuses to launch.
  • Wording. The page-2 note, the launch message, the ready message, the Case comment and the triage message now say legal still approves the Ironclad workflow before anything is signed.
  • Docs. docs/ironclad-launch-prereqs.md is updated: the ESA table, the unsent-attribute list, the signature-step check (done), when the lane can be switched on, and the recordType row (always Customer ESA since 309c575).

Enabling

With this PR deployed, DOC_GENERATE_ENABLED=on is safe for both the DPA and the ESA. Nothing auto-sends to the counterparty.

Test and deploy notes (Test, Redeploy) are on GitHub · Open on GitHub ↗
#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
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…
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.
Test and deploy notes (Test, Manual, Redeploy) are on GitHub · Open on GitHub ↗
#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
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…
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.

Review round 1 (Codex, 9/11 22:20)

  • Cross-PR hazard closed: the helper stays boolean for every existing caller (uploadFileToSlack); the NDA poller uses the new uploadFileToSlackWithEvidence. #74's if (posted) and #73's doc-ready.ts merge unchanged.
  • Evidence no longer depends on the response shape: Slack's documented minimal completion response (files:[{id,title}]) now triggers a files.info read for the permalink + shares; if that fails the line falls back to plain wording and ndaSignedDocInSlack still reflects the accepted share.
  • Filename escaping: the link label (an Ironclad filename) has & < > | escaped so it can't close the link or mention anyone.
  • Not changed, known and pre-existing: the terminal Firestore write comes after the Case attach + Slack upload (a transient write failure repeats both on the next pass), and the chat.postMessage after the terminal write is best-effort. Both predate this PR; the promise-ledger design from 9/09 is the fix.
  • Harness now 10/10.
Test and deploy notes (Test, Manual, Redeploy) are on GitHub · Open on GitHub ↗
#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
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:…
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. Send now and Have legal review it first still require them, enforced server-side inline on the blocks (NDA_REQUIRED, the same seven as before). Copy under the radio says which fields to skip.

No account (page 2 anchor). The account picker is optional for NDAs, hint "leave it blank for HR, procurement or other non-customer NDAs". The Case files with neither AccountId nor Opportunity__c and the description reads "Account: none — internal / HR / procurement NDA". Production already has 616 NDA Cases with no account, so this is the ordinary Salesforce case, not a new shape. Send-now without an account launches with the signer from the form and no salesforceAccountId; no Contact is created (that path was beta-terms-only anyway). Every other request type still requires an anchor.

Web app: 'nda-template' added to the selfServe union; chip / detail pane / modal copy for it. No rules or index changes.

One-time setup (the .docx)

The file is not in the repo and not on my machine (asked for it 9/11 17:19). docs/nda-word-template.md has the two gsutil cp lines — it goes in the bucket lfdKnowledge.ts already reads (gs://<project>-lfd-knowledge/templates/BetterUp Mutual NDA.docx), so the runtime's existing read grant covers it and no IAM changes. Path/filename/bucket can be overridden in config/legalTriage.ndaTemplate (no deploy) or by env. Until it's uploaded the lane degrades to a normal NDA request, as above.

Review round 1 (Codex, 9/11)

  1. Fixed — strict upload result. The template site now reads the upload through templateDelivered(posted) (=== true). An object-shaped return such as { ok: false } reads as not delivered, so the ticket and Case can never close over a failed upload. Harness: true → delivered; {ok:false}, {ok:true}, "true", 1, undefined → not.
  2. Fixed — closes happen before they're announced. Ticket close and Case close run first; a refused setRequestStatus (ok:false or throw) and a failed Case PATCH are each recorded. The requester line, the modal, the triage FYI and the ticket fields state only what happened: delivered + both closed / delivered + ticket closed, legal is closing the Case (FYI names the Case that did not close) / delivered + the request stays open for legal to close (selfServe cleared so a human can close it; FYI says why). Harness: close-before-announce ordering on the happy path, ticket close fails, Case PATCH fails, plus the modal now distinguishes "not on file" from "couldn't post".
  3. Known, not changed tonight — creation notification. lfd-notify.ts rings every triager with "New legal request" on the ticket's create, before selfServe is written, so a template-only request produces a notification that is never withdrawn. Same pre-existing behaviour as the ironclad-nda and sf-beta-terms lanes. Proposed one-liner: write selfServe in the same create as the ticket (pass it into recordIntake), or have the notifier skip docs with selfServe set.
  4. Joe's call — "Anything else for the record". Still required in template mode: #58 made it required on Joe's 9/10 instruction and this PR leaves that alone, so a template request needs one line in that field. Drop the requirement for template mode if he wants.
  5. Stated — lane gating. The whole "How should this go out?" block, and so the template option, only renders when the Ironclad lane is configured (ndaLaneReady). Fine for prod; on a deployment without the Ironclad secrets the option is absent, not broken.
  6. Merge notes. (a) loadTriageConfig in lfd-triage.ts now conflicts three ways — this PR (ndaTemplate), #69 (sfMirrorSilent), #71 (feedbackChannelId); whoever lands last must compose all three, not pick one. (b) #73 touches page-2 validation and the submit parse; the resolution must keep THIS branch's three-way nda_mode parse (readNdaMode: send / review / template) — a two-way parse turns "template" into an Ironclad send.

Harness after this round: 66/66. tsc clean (functions + app). The 9/09 formshape-harness is green; the 9/09 nda-harness has one pre-existing failure ("all signed, no document retrievable" expects an immediate signed; the retry-before-finalising behaviour that shipped after 9/09 leaves it pending on that pass) — identical on unmodified main, not from this branch.

Test and deploy notes (Test, Manual, Redeploy) are on GitHub · Open on GitHub ↗
#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
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…
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 Ironclad launch — see below.
  • Frontend mirror src/components/Chat2/requestTypes.ts matches (labels, one ESA, MSA → MSA instead of Other).

Cutover: Salesforce picklist

ESA and MSA are new Agreement_Type__c values on the Legal Contracting (Deal_Desk_Legal) record type. I am adding them to BU_Full tonight; prod needs the same before go-live (the four ESA – … values and MSA Review can stay on the field for history; they come off the record type). Until an org has them, createLegalCase files the request as Other, states the real type in the description, and logs record type does not offer Agreement_Type__c "ESA" — filing as "Other" — so a missed cutover is visible in the function log, not silent.

Ironclad: jurisdiction attribute key still to fill

There were no Ironclad API credentials on the build machine, so the ESA schema's attribute name for jurisdiction is not hardcoded. Three env vars (functions runtime): IRONCLAD_ESA_JURISDICTION_ATTRIBUTE (the schema key), IRONCLAD_ESA_JURISDICTION_US (default US), IRONCLAD_ESA_JURISDICTION_NON_US (default Non-US). With the key unset the answer is not sent (a guessed key would 400 every ESA launch) and one warning per launch says jurisdiction "…" collected but NOT forwarded; the Case and triage card still carry it. docs/ironclad-launch-prereqs.md has the exact GET /workflow-schemas/67239caaf8b652f37bf7bb8d?form=launch read to find the key and its two values. Nick: if the ESA template has separate US / non-US workflows rather than one attribute, say so and this becomes a second schema id instead — same one-line change in attributesFor() / DOC_TEMPLATES.

Review round 1 (ace355d)

Codex review found four real problems; all fixed and proved in request-types-harness.cjs, now 62/62 (was 46), plus docgen-harness (34 pass, 0 failing — its ESA launch check updated to the new rule), formshape 17/17, picklist all pass. tsc clean, functions and app.

  1. Fail CLOSED on jurisdiction (was: launched on the template default and told the requester it was generating). An ESA now generates only when the ticket carries a US / non-US answer and IRONCLAD_ESA_JURISDICTION_ATTRIBUTE is set. Either missing ⇒ no launch: the request files as an ordinary our-paper ESA, the Case description carries "Jurisdiction not collected — not generated; legal to draft" or "Jurisdiction attribute not configured in Winston — not generated; legal to draft", the ticket gets docGenerateSkipped: "no-jurisdiction" | "no-attribute-key", the requester is told legal will draft it (and, if the answer was the gap, asked to reply with US / non-US), the triage card's thread gets the same line, and it is logged. Enforced twice: in the filer (esaGenerateBlocked() before the description is built) and inside launchDocumentWorkflow itself, so no caller can launch an ESA without it — the React Chat 2.0 path submits no jurisdiction today and therefore never generates an ESA; wiring the question into Chat 2.0 is a follow-up, not tonight. DPA generation is unaffected. The earlier "collected but NOT forwarded" warning path is gone — it was the bug.
  2. Background filer no longer proceeds on esaJurisdiction: null. Same gate; the reason is written to the ticket as above.
  3. Stale page 2. A page 2 opened before this deploys has no esa_jurisdiction block, and an error keyed to a block that isn't on the view is invisible — the submit was dead. respondToIntakeView now errors only when the block is present and unanswered; an absent block is accepted, files with no jurisdiction, and item 1 keeps it off Ironclad. Both shapes in the harness.
  4. Boolean schema attributes. IRONCLAD_ESA_JURISDICTION_US / _NON_US values true / false (any case, trimmed) are sent as JSON booleans; anything else is the string verbatim; empty falls back to US / Non-US. Documented in docs/ironclad-launch-prereqs.md.

Merge notes. (a) This conflicts with #74 around the page-2 validation and the submit-time parse. Whoever resolves must keep #74's three-way nda_mode parse (send / review / template) — taking this branch's side of that hunk wholesale turns "Provide me with the Word template" into an Ironclad send. (b) #75 keeps uploadFileToSlack boolean, so doc-ready.ts is untouched here — nothing to do.

Review round 2 (43d3a64) — against the real launch schemas

The ESA, DPA and NDA launch schemas were read on 2026-09-11 (GET /workflow-schemas/{id}?form=launch with x-as-user-email; raw JSON kept with the harnesses). Calibration: the live NDA lane sends exactly the NDA's required: "always" set and launches, so "always" is the API-enforced set. docs/ironclad-launch-prereqs.md now carries both tables.

  1. Jurisdiction key hardcoded as the default. ESA uSBased_c5fac8bd-a073-472d-8ddc-315d2e2dcd4c_boole (boolean, "Is this customer based in the US?"): US ⇒ JSON true, non-US ⇒ false. The env override stays for a redesigned template; the fail-closed gate now trips only if someone sets the key env to empty.
  2. attributesFor() sends everything Winston knows. Both: paperSource: "Our paper", counterpartyName, salesforceAccountId when present. ESA adds the jurisdiction boolean, recordType: "Customer ESA" and legalReview: false. DPA adds whereIsTheCustomerUsingTheBetterUpPlatform from a new required page-2 radio for every DPA, "Where is the customer using the BetterUp platform?" → USA only / Global (including USA) (dpaRegion on the ticket, "Platform region: …" in the Case description, card label "DPA · Global"). Same stale-modal rule as jurisdiction: block absent ⇒ accepted, no launch, docGenerateSkipped: "no-region", requester and triage told. Gov ESAs: recordType also offers Customer ESA (Gov). The Government variant was one of the collapsed request-type rows, so today every ESA generates as the non-Gov record type. Nick — if Gov ESAs should generate as Gov, a "Government customer?" radio on page 2 is a one-line add; say the word.
  3. Not invented: signer / contact / notices fields. ESA still requires Customer Signer Name (rolef639afcb6e10454c926ba49a54bf4cc9), counterpartySignerTitle, Customer Signer Email (role18d355100e014f49a6887a0622db29ef), contactName and customerAwareOfAi; DPA requires Counterparty Signer Name (role6ffac7aa32bd41ce86615ab371bae60e), Signer Email (role367b0536e6c34f7c89d30ed97b0c4982), counterpartySignerTitle and counterpartyEmailForNotices. The requester supplies those when they send the draft, so Winston does not know them at generation time and must not make them up. Until those are optional on the launch form, every ESA / DPA launch is a 400 and the existing graceful path runs (ticket filed, "legal will draft it"). The 400's field list now reaches the error string, the function log (POST /workflows (ESA) 400: Missing/invalid: contactName, …), the ticket (docLaunchError) and the triage card's thread — #70 only logged the first 400 chars of the body and told the requester a generic line; launchRejectionFields() reads fields[], errors[].field|path|attribute|key, and known keys named in plain text. Ironclad side (Joe) — checklist, also in the doc: ESA → make the five fields above optional/defaulted at launch; DPA → the four above; confirm neither template auto-advances into a signature step after generation.

Test. request-types-harness.cjs 92/92 (was 62): exact ESA payload with no env (default key ⇒ true/false, recordType, legalReview, signer fields absent), exact DPA payload (USA only / Global (including USA)), DPA page-2 radio + required-when-shown + stale-absent, no-region skip end to end (ticket, description, requester, triage), blank-key and no-answer fail-closed paths unchanged, env override + boolean parsing, launchDocumentWorkflow refuses ESA-without-jurisdiction / blank key / DPA-without-region before any call, launchRejectionFields on three body shapes, and a fake 400 listing contactName surfacing in the ticket, the log and the triage thread while the requester's last word is "legal will draft it". docgen-harness 34 pass / 0 failing (launch fixtures updated to the real payloads), formshape 17/17, picklist all pass. tsc clean, functions and app.

Redeploy unchanged: lfdIntakeProcess, lfdTriageAssign, lfdSfMirror, lfdCaseOwnerPoll, plus hosting:winston.

More on GitHub.

Test and deploy notes (Test, Manual, Redeploy) are on GitHub · Open on GitHub ↗
#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
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…
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 the sandbox client-credentials app (Run As (email).legaldashboard.full, profile Read Only - API) runs the exact FeedItem + FeedComments SOQL and reaches /chatter/users/me — no permission error. BU_Full simply has no human posts on Cases yet (feed is all TrackedChange / status / CaseComment rows), so the first live entry will come from the manual test below. At prod cutover, re-run the same SOQL as the prod integration user before trusting it; prod Legal Cases carry ~40 TextPosts and ~36 ContentPosts a month.

Review round 1

Codex review found four things; all fixed in 9165139, each pinned by a harness case.

  1. Pagination. readCaseChatter read only the first records page; child FeedComments count toward the 2,000-row batch, oldest first, so newer posts could fall off and an absent Case then read as an empty feed. Now a queryAll in sf-demo.ts follows nextRecordsUrl until done (same contract as the jobs' helpers), a failed page throws rather than presenting a partial feed, and a Case absent from the result set is a skip, never a clear. Harness: two-page response merged in order; page 2 failing leaves the stored window untouched with chatterErrors counted and the owner still synced; an absent Case with a stored window writes nothing.
  2. Coupled write. One merged write meant a ticket doc at the 1 MiB limit would have rejected the owner/status stamp along with the feed on every pass. The owner/status write is back to its own set, byte-for-byte the pre-Chatter shape; Chatter is a second set. Bodies are clipped to 2,000 chars and the stored text is bounded at 200 KB, oldest dropped first. Harness: a Chatter write that throws still lands the owner change and the close; the clip and the budget are checked.
  3. Author drift. chatterChanged compared id + body only, so a renamed Salesforce user kept the old name forever. It now compares author and time too; the harness case that codified the old behaviour is corrected and a renamed-author pass writes.
  4. Empty-body posts. A file drop or link share with no text was dropped and its comments looked orphaned. Title and LinkUrl are selected and an empty-body ContentPost / LinkPost renders as "<author> shared a file: <Title>" / "shared a link: <Title> <url>"; an empty TextPost is still dropped.
Test and deploy notes (Test, Manual, Redeploy) are on GitHub · Open on GitHub ↗
#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
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,…
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-and-drops the rest. Found by reading the dispatcher; the harness would have passed either way, so it's called out here.

Email (feedback-link.ts, new; email.ts; forecast/render.ts; jobs/forecast-report/src/render.ts). One footer line on each outgoing template — Feedback on Winston? Open Winston in Slack and tap Feedback — linking https://slack.com/app_redirect?app=<WINSTON_SLACK_APP_ID>. Env WINSTON_SLACK_APP_ID, prod default A0B34MYCREE; staging should set A0BRN56PBQA (two apps, same display name). The CI job has its own copy of the helper since it doesn't share functions/.

Rules (Nick deploys)

firestore.rules: lfdFeedback readable by legal with the module, write: false — same gate as legalIntakeRequests. Nothing reads it in the web app yet; the channel card is the read path for the pilot.

Merge

loadTriageConfig in lfd-triage.ts now conflicts three ways: #69 adds sfMirrorSilent, this PR adds feedbackChannelId, #74 adds ndaTemplate — all on the same value = {…} line. Whichever lands last must COMPOSE all three fields — sfMirrorSilent, feedbackChannelId, ndaTemplate — not take one side.

Review round 1 (Codex, 9/11) — 36978e5

  1. Feedback lost on a transient Firestore failure (HIGH) — fixed. lfdIntakeProcess claims the queued submission before the handler's add(), so a failed write had no redelivery and vanished silently. Now the card — which carries the whole text — is posted to the channel regardless, marked Not saved to lfdFeedback… this card is the only copy, and the ack is derived from what happened (ackLine: saved+posted · saved only · posted only ("reached legal ops in their channel, though it didn't save to the feedback log — nothing to redo") · neither ("didn't get through — tap Feedback again, or DM Nick")). The claim-failure path in notifyFilingDropped now has feedback-specific wording and skips the view update (the modal already cleared on the ack); the request-form wording is untouched.
  2. mrkdwn injection + section length (HIGH) — fixed. User text, request ref and display name are escaped (& < >) before any block; the body is its own section (no per-line > ), clipped to 2,800 chars pre-escape and hard-capped at 3,000. Proved with <!channel> / <@U123> / <https://x.y|click> / <!here> and a 1,000-line body. The requester's own <@U1> mention stays real; the stored doc keeps the raw text.
  3. Deep link team id (MEDIUM) — fixed. slack.com/app_redirect?app=…&team=T02S4APAXMJ via WINSTON_SLACK_TEAM_ID (default the BetterUp workspace) in both feedback-link.ts and the CI job's render.ts.
  4. Merge note above.

Known, accepted for the pilot: feedback submitted from a plain (non-threaded) DM is acknowledged at the DM root rather than in a thread — there is no thread to reply into. The door on a greeting card, and the typed-word button, both sit in a thread and ack there.

Harness: 49/49 (was 32/32). tsc clean; the CI job's render.ts typechecked standalone.

Test and deploy notes (Test, Manual, Redeploy) are on GitHub · Open on GitHub ↗
#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
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…
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 FYI in practice), and it closes from Slack or Winston like any other request. Whoever sends manually owns the outcome, so nothing auto-closes.

Before merge — two things only Ironclad can answer

  • [ ] Required launch attributes on the DPA and ESA schemas. The payload is counterpartyName (+ salesforceAccountId when known), per Nick. If a schema requires more, the launch fails with a 400 naming the field (logged, requester told legal will draft) — add it in attributesFor(), the single place the payload is shaped.
  • [ ] Both configurations stop after generation. If either has a signature step that fires automatically, generating is sending. Needs a review/hold step in Ironclad first.

Also: every ESA variant maps to the one ESA template. If Government or Financial Institutions should stay lawyer-drafted, prune DOC_TEMPLATE_BY_CATEGORY_KEY.

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
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…
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.
Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
> 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.
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.

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
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…
Merged

What

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.
Test and deploy notes (Test, Redeploy) are on GitHub · Open on GitHub ↗
#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
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.
Merged

What

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.

Change (functions/src/lfd-triage.ts only)

  • After the new assignee's DM is posted and anchored, assignRequest posts the ticket's messages[] into that DM thread — oldest first, one line per message in the relay's own format (viewer-local <!date> timestamp, name, (legal) marker, attachments), chunked at 3,500 chars and numbered n/N, with 🔁 Handed over from <previous owner> on the first post. Because that thread is the relay anchor, the new lawyer's first reply sits directly under the history.
  • A first assignment on an unowned ticket that already collected requester notes gets the same transcript with a "added while the ticket was waiting" intro. A same-owner re-assign (mirror re-sync, second click) posts nothing.
  • The previous owner's DM thread stays in lfdThreadIndex as side: "assignee" today, so a reply typed there after the handover would have been relayed to the requester as legal's answer from someone no longer on the ticket. Its index entry is now deleted and the thread gets one line: "This request is now with X — replies here no longer reach the requester."
  • Transcript and retire are best-effort like every other Slack step in assignRequest; a failure logs and never fails the assignment.
Test and deploy notes (Test, Redeploy) are on GitHub · Open on GitHub ↗
#65
lfd: triage card carries the Winston ticket link
Nick RaffeyLegal Front Dooropened 2026-09-11merged 2026-09-111 file, +3 / −0
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.
Merged

What

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.

Why

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).

How

One field added in triageBlocks (functions/src/lfd-triage.ts), reusing ticketUrl(). Because every card re-render (assign, status move, SF mirror) goes through the same builder, existing cards pick the link up on their next update. Fields count tops out at 9 of Slack's 10.

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
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.
Merged

What

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.

Why

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.

Fix

Same pattern Deal Desk already uses for its quarter chip (DealDeskView.tsx, Sarah's 8/04 report): render the menu through createPortal into <body>, position: fixed at the button's getBoundingClientRect(), with a click-outside scrim. Closes on scroll/resize so a fixed menu can't drift from its button. Mouse-leave close and the option list are unchanged.

One file, src/components/LegalFrontDoor/SplitQueue.tsx. tsc -p tsconfig.app.json --noEmit and eslint on the file both clean.

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
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).
Merged

What

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.

How

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.
Test and deploy notes (Test, Deploy) are on GitHub · Open on GitHub ↗
#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
Two small changes to lfd-triage.ts, one commit each.
Merged

What

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.

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
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…
Merged

What

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.

Test and deploy notes (Test, Deploy) are on GitHub · Open on GitHub ↗
#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
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:
Merged

What

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.

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
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.
Merged

What

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.

Why now

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.

What the relay does

GitHub's schedule (cron) trigger is best-effort and ran 1–4h late for 24h+ in Aug 2026, which flooded the health monitor with stale-pipeline alerts. workflow_dispatch fires immediately. So Cloud Scheduler owns the cadence and pokes GitHub on time; the workflows' own crons stay as a fallback, and their concurrency groups plus the dispatcher's queued-run guard absorb any overlap.

Auth: GH_DISPATCH_TOKEN secret (fine-grained PAT, this repo only, Actions read+write). Already provisioned in prod; the staging env has a placeholder.

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
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:
Merged

What

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.

Still optional — each for a structural reason, say the word and I'll flip any of them

  • Have the licenses been deployed? / Swap rights currently permitted? — License Swap amendments only; required would force every other amendment subtype to pick an irrelevant value.
  • Quote on Order Form — blank means "use the opportunity's primary quote", which is the common case.
  • Beta Terms signer — it's an either/or: pick an existing contact or type a new one. Slack can't express "one of these"; requiring both blocks every submission.
  • Attach documents — many requests have nothing to attach.
Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
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.
Merged

What

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

How

  • 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).

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#56
lfd: Winston answers only from the Legal Bot Knowledge spreadsheet
Joe ChiusanoLegal Front Dooropened 2026-09-11merged 2026-09-114 files, +111 / −105
Winston's Q&A now answers from the Legal Bot Knowledge spreadsheet and nothing else (Joe, 2026-09-10).
Merged

What

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

How

  • 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-routing line.

Why the second commit

The COI answer in the sandbox (2026-09-10 18:05) cited ESA, §11.7 (Insurance) next to the FAQ row and told the asker what the ESA commits us to. That came from the row's Authority Source / Policy Quote columns, which the first commit still sent and allowed the model to relay. Now those columns stay in the sheet.

Notes

  • The loader reads faq/Legal Bot Knowledge Base v3.xlsx from the knowledge bucket. If the sheet legal wants live is the v2 workbook under a different object name, upload it at that path or tell me the name and I'll change the constant — I can't list the bucket from my account.
  • Behavioural effect: questions the sheet doesn't cover now escalate instead of being answered from the ESA/DPA text. That's the intent.
#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
Follow-on to #53. The third "Whose paper?" answer now writes a value too:
Merged

What

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.

How

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
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.
Merged

What

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.

How

  • 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, Case second on purpose — the SF mirror then sees an already-closed ticket and doesn't post its generic "your request is done" line over the poller's own requester message. Both stamp sfCaseClosedByWinston so a later Winston-side close doesn't re-PATCH. 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 instead of ⏳ 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 throughout, so the mirror's assignment branch stays suppressed and no lawyer is DM'd; the queue's Unassigned count already excludes self-serve tickets.

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
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.
Merged

What

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.

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
Two lines, reusing #36's plumbing:
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.

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
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:
Merged

What

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.

Relation to #50 (Joe, joe/lfd-close-announce)

#50 fixes the "requester not told" half: setRequestStatus gains source: "winston" and, only for that source, posts the requester note plus a new message into the assignee's DM thread ("was closed from the Winston queue by <name>…"). It does not remove the live Mark closed button from the assignee DM, has no reopen handling, and leaves three separately-written requester copies in place (button, mirror, web). Its email roster field and roster-name resolution are re-implemented here. This PR is based on origin/main and supersedes #50 — merge one, not both (both touch the same lines of setRequestStatus).

Callers and who tells the requester

  • Caller — status — requester copy
  • lfdTriageAssign (Winston queue) — any — shared path (new)
  • handleCloseClick (Slack button, card or DM) — closed — shared path (moved here from the handler)
  • handleSfMirrorChange non-silent SF close — closed — its own "closed in Salesforce" line — passes tellRequester: false; still never writes back to Salesforce (unchanged: deps.sfCreds absent on lfdSfMirror)
  • self-serve NDA (lfd-intake.ts) — closed — Winston's own NDA message — passes tellRequester: false
  • reply relay bump assigned → in_progress (lfd-intake.ts) — in_progress — not a close/reopen, nothing new fires

tellRequester only governs the requester note; card + DM button handling runs for every caller.

Retest

  1. Web close, Case-backed ticket, closer ≠ assignee: one ✅ … closed by <roster name> in the requester thread; triage card and assignee DM both lose Mark closed and show 🗄️ Closed by <name>; SF Case closed. Nothing else in the DM.
  2. Web close, closer = assignee: same as 1.
  3. Slack Mark closed from the card: exactly one requester note (no double); clicker sees 🗄️ Closed — … The requester's been told. in the channel; assignee DM button gone.
  4. Slack Mark closed from the assignee DM: same, confirmation lands in the DM thread; the DM message it was clicked on loses its button.
  5. Reopen from the web (closed → in_progress): ↩️ … reopened by <name> in the requester thread; DM gets Mark closed back with an updated context line.
  6. Close an already-closed ticket (web or Slack): no requester post, no DM change (Slack: "Already closed — nothing to do. ✓").
  7. SF-side close (Case closed in Salesforce): requester gets only the existing "closed in Salesforce" line; DM button stripped; no writeback.
  8. Self-serve NDA: requester gets only Winston's NDA message.
  9. Roster entry without email: web close still works, "by" shows the email.

tsc --noEmit and eslint clean on functions/src/{lfd-triage,index,lfd-intake}.ts.

Redeploy after merge — the functions that execute setRequestStatus / handleCloseClick / dmAssignee: lfdTriageAssign (web close), lfdIntakeProcess (queued Slack button clicks, self-serve NDA, relay bump, question-door assign), lfdSfMirror (SF-side close). slackCalcAction only enqueues lfd_triage_* clicks for lfdIntakeProcess, so it's unaffected behaviourally (harmless to include). lfdWeeklyDigest / lfdNdaSignedPoll / lfdBetaTermsPoll / lfdProcessInbound don't reach the changed code. Remember the functions predeploy build (stale-lib footgun).

#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
When legal closes a ticket from the Legal Front Door queue, Slack now hears about it:
Closed

What

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.

Why it was silent

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.

How

setRequestStatus takes an optional source. Only "winston" triggers the announcements, and only on a transition into closed — closing an already-closed ticket says nothing, and no other status move announces. The two existing callers pass no source, so their behaviour is byte-for-byte unchanged and nothing double-posts. TriageRosterEntry now declares email (the Firestore entries already carry it; the web hook already reads it).

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#49
functions: deploy map — which exports to redeploy per source file
Nick RaffeyPlatformopened 2026-09-103 files, +241 / −1
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 —…
Open

Why

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?".

What

  • 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.
Test and deploy notes (Verified) are on GitHub · Open on GitHub ↗
#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
Both Case PATCHes now send Sforce-Auto-Assign: false:
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.

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
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.
Merged

What

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 design, unchanged.

Prerequisites

  • Case Edit on the integration user — in place in BU_Full as of today (LFD_Case_Create). In prod the permission set is built but unassigned until cutover, same as everything else on that user.
  • sfUserId on roster entries in config/legalTriage — only mapped people can be chosen for Case-backed tickets. Anyone without one still works for questions.
Test and deploy notes (Files, Test) are on GitHub · Open on GitHub ↗
#46
case-mirror: poll Salesforce every 15 minutes instead of 30
Joe ChiusanoLegal Front Dooropened 2026-09-10closed 2026-09-152 files, +6 / −3
Cron on legal-case-mirror.yml goes from /30 to /15. README updated to match. No code change.
Closed

What

Cron on legal-case-mirror.yml goes from */30 to */15. README updated to match. No code change.

Why

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.

Cost

Each run is ~20-30s with a handful of SOQL reads, so 96 runs/day instead of 48 is negligible on Actions minutes and SF API calls. GitHub cron is best-effort; expect 15-25 min in practice.

Not in this PR

Owners who are not in the Winston roster still land "name-only" (no DM, no reply relay). Flor Aparicio hit that path today. Separate fix: add roster entries with sfUserId.

#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
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.
Merged

What

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.

Salesforce — already live in BU_Full and prod

  • Case.Quote__c — Lookup → SBQQ__Quote__c
  • FLS read+edit on LFD_Case_Create (both orgs)
  • Field added beside Opportunity on the Deal Desk: Legal layout (both orgs)
  • The integration user already reads SBQQ__Quote__c and writes Opportunity__c via its profile.

Still needed in Setup before this routes correctly (Joe)

  1. Expose Order Form on the Legal Contracting record type — it's an active Agreement_Type__c value but not currently offered to that record type, so until then createLegalCase's fallback files it as Other with the real type in the description.
  2. Case Routing entry: Agreement Type = Order Form and Record Type = Legal Contracting → owner TBD, above the catch-all.

Not in this PR

  • A quote line on the Slack receipt — createLegalCase returns quoteName for it, not yet rendered.
Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
Offers the opportunity picker on the request types that can take it, so filings associate the way legal's existing records do.
Closed

What

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.

Why the opportunity anchor rather than either

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.

Two deliberately unchanged

NDA and Beta Terms stay account-anchored. Both show a signer picker, and it narrows its contact search to the account the requester chose:

searchSandboxContacts(deps.sfCreds, picked.accountId, term)

Offer an opportunity instead and there is no account to narrow by, so the search widens past it — the case #36 is being held for. Moving these two would have introduced that rather than avoided it. They follow once the picker carries the opportunity's account through.

Test and deploy notes (How verified, Not yet verified) are on GitHub · Open on GitHub ↗
#43
lfd: collect the eight fields Salesforce requires on amendments
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-102 files, +146 / −0
Collects and sends the eight fields Salesforce requires on an amendment request.
Merged

What

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.

Before this reaches production

Eight field permissions are needed on the integration user — read and write on each field above. They are granted in the full sandbox; production does not have them yet, and without them the fields cannot be written even though the validation rules still demand them. This is not a code-only change.

Also worth passing to whoever owns the org: Legal_Requesting_Party_Required tests RecordType.Name = "Legal Contractin" — the final letter is missing, so it does not match the record type and that field is not currently enforced on any Case. The other seven spell it correctly. This PR collects the field regardless, so nothing here depends on it.

Test and deploy notes (How verified, Not yet verified) are on GitHub · Open on GitHub ↗
#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
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.
Merged

What

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.

Worth confirming

Point 1 rests on MSA reviews always being counterparty paper. That is asserted in this codebase; I have not seen it confirmed as a business rule. If there are cases where BetterUp's own template is used for a master agreement, this should offer the choice rather than coerce it — worth a yes or no from you before merge.

Test and deploy notes (How verified, Not yet verified) are on GitHub · Open on GitHub ↗
#41
lfd: requester view ordering, nudge stamping, and intake retention
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-105 files, +89 / −5
Three changes, and I would suggest taking them as two.
Merged

What

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.

Test and deploy notes (How verified, Not yet verified) are on GitHub · Open on GitHub ↗
#40
lfd: tighten three authorisation and environment checks
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-103 files, +57 / −2
Tightens three checks so each one tests the thing it is actually protecting.
Merged

What

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.

Why this shape

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.

Test and deploy notes (How verified, Not yet verified) are on GitHub · Open on GitHub ↗
#39
lfd: align four requester messages with what the system did
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-103 files, +98 / −16
Brings four requester-facing messages into line with what the system actually did, and closes the loop when a ticket stops being self-serve.
Merged

What

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.

Worth knowing before merge

The Salesforce close is a new outward effect from triage and has not been exercised in a deployed environment. A staged close, reopen and close again is the sequence worth walking before this reaches production.

sfCaseClosedByWinston is set on close and not cleared on reopen, so a later close could skip Salesforce having already recorded one. Small, and worth a follow-up.

Test and deploy notes (How verified, Not yet verified) are on GitHub · Open on GitHub ↗
#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
Two things, plus the database indexes both pollers need.
Merged

What

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.

Merge this before #32

#32's ordered query needs the ndaOutcome index defined here. Taken the other way round, that query has no index to serve it. Deploy the indexes and let them finish building before either reaches production.

Worth deciding

This raises "every row in this run failed", which is the condition that hides a permissions or credentials problem. It does not raise "the run did not happen" — if the scheduled function throws before completing, there is nothing to report from. An independent staleness check on the last successful run would cover that, and is a clean follow-up rather than something to fold in here.

Test and deploy notes (How verified, Not yet verified) are on GitHub · Open on GitHub ↗
#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
Holds back the "still unsigned" chase when the system has no way to see a signature at all.
Merged

What

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.

Why this matters here

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.

Worth knowing

The freshness signal is the newest status row in the environment, from any envelope. That is deliberately broad so it degrades safely, but it does mean unrelated healthy traffic would satisfy it. Narrowing it to the relevant configuration is a reasonable follow-up if you want the signal tighter.

Test and deploy notes (How verified, Not yet verified) are on GitHub · Open on GitHub ↗
#36
lfd: match an existing contact in the signer picker
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-102 files, +129 / −20
The Beta Terms signer picker searches contacts on the account the requester chose, reliably, and never anywhere else.
Merged

What

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).

Test and deploy notes (Test) are on GitHub · Open on GitHub ↗
#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
Three small fixes to what the request form tells the person using it.
Merged

What

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.

Worth deciding

ndaLaneReady() currently tests client id and secret. Ironclad reads also require asUserEmail, so adding it to the predicate would close the remaining case where the form advertises a lane whose reads cannot authenticate. Happy to add that here if you would rather it did not wait.

Test and deploy notes (How verified, Not yet verified) are on GitHub · Open on GitHub ↗
#34
lfd: environment interlock on outbound signature requests
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-102 files, +99 / −1
Adds an environment interlock so a signature request can only reach an external counterparty from production.
Merged

What

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.

Why this shape

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.

Worth deciding

The override is symmetric: true opens external sending in any runtime, not only production. That is deliberate — it doubles as the kill switch, since false closes production. If you would rather it could only ever close, that is a small change and worth making before this merges.

Test and deploy notes (How verified, Not yet verified) are on GitHub · Open on GitHub ↗
#33
lfd: claim the intake work before filing it
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-103 files, +142 / −0
Claims the inbound intake document before any of the filing work begins, so a repeated delivery cannot repeat the work.
Merged

What

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.

Why this shape

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).

Worth stating plainly

The claim is taken before the work, which converts at-least-once delivery into at-most-once. If the process dies between the claim and the Case insert, that filing stops rather than duplicating. That is the safer direction when the alternative is a second real signature request, but it is a trade rather than exactly-once, and staged checkpoints would close it properly. Worth tracking as follow-up.

Test and deploy notes (How verified, Not yet verified) are on GitHub · Open on GitHub ↗
#32
lfd: confirm NDA execution from Ironclad's sign-status
Joe ChiusanoLegal Front Dooropened 2026-09-10merged 2026-09-102 files, +232 / −57
Makes the signed-NDA lane confirm execution from something Ironclad states, rather than infer it from the name of a returned file.
Merged

What

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.

Why this shape

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.

Before merging

Two prerequisites, both outside this diff:

  • #38 first. The ordered query needs the ndaOutcome/createdAtMs composite index, which is defined in #38. Deploy the indexes and let them finish building before this reaches production.
  • Confirm the Ironclad scopes. The token request asks for four, including public.workflows.readSignStatus. If any one is not granted on the API client the whole token request fails, so the full set is worth checking rather than just the new one.
Test and deploy notes (How verified, Not yet verified) are on GitHub · Open on GitHub ↗
#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
#30 was the NDA half of self-serve follow-through. This is the beta-terms half.
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.

Access — no cutover step

The poll reads two FLS-gated Case fields plus dfsle__Log__c. Rather than leave that as "remember to assign a permission set", the grants now live in LFD_Case_Create — the permission set that exists solely for this integration user and is already a required cutover artifact. Beta-terms access ships with the thing that lets the front door file a Case at all.

The permission set is now committed at salesforce/permissionsets/ so a cutover is sf project deploy start -d salesforce, not a Setup click.

Deployed to BU_Full and verified after the deploy: all six field grants present, both objects, Case.Deal_Desk_Legal record type visibility intact, existing assignment untouched. The stopgap BU18_Beta_Terms assignment I made earlier has been removed — that permission set is for the beta-terms users, not this integration.

⚠️ Two traps written up in salesforce/README.md: a PermissionSet deploy replaces the whole set (retrieve before editing, or unlisted grants are silently deleted and it still says Succeeded), and (email).legaldashboard.* exists in both BU_Full and BULegal with the same Id 005Pd00000D1TLRIA3, so work applied to the wrong sandbox is indistinguishable afterwards.

Open question for you

intake-record.ts still has beta in IRONCLAD_CATEGORY_KEYS, so beta requests get the Ironclad-type chip we set the copy on last week. Cosmetic — it only drives the chip — but it contradicts the live path. Say the word and I'll move it to sf-case in this PR.

Test and deploy notes (Verification) are on GitHub · Open on GitHub ↗
#30
lfd: the signature packet is not the executed NDA
Joe ChiusanoLegal Front Dooropened 2026-09-09merged 2026-09-091 file, +13 / −3
One-line fix to the ranking in nda-signed.ts, found while looking at where a Winston-filed NDA is stored.
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
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.
Closed

What

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.

Where it runs, and why not in Winston

The launch client can't do this: createWorkflows / readWorkflows / readSchemas only, and adding readDocuments to it would give the one credential that can generate live contracts read access to every workflow's documents — undoing the write/read split the 08-18 build set up deliberately.

The read-only ironclad-sync client already carries readDocuments, and .github/workflows/sync.yml already runs it every 30 minutes **with the SF_DEMO_* sandbox credentials in the same job. Both credential sets were already in one runtime, so the attach runs there as a step: no new secret in any store, no Ironclad admin change, no new API client.**

The same logic is also wired as a Firebase scheduled function (lfdNdaDocSync) and left undeployed — the right home if this should ever live inside Winston, but it would cost three new Secret Manager entries whose values are only held as GitHub secrets.

Evidence

Two dry runs against live Ironclad, from this branch:

scanned 2 · would attach 2 · versioned 0 · unchanged 0 · pending 0 · cancelled 0 · errors []
signed/niQrDF5i-T5r_FKnCt_az v1 (EXECUTED)                  on 00024673
sentSignaturePacket/jI6O-Nmi7BVhvZ1q8wVGV v1 (not executed) on 00024678

The first run caught a defect in the build: the NDA template carries a sentSignaturePacket attribute (the paper that went out for signature) alongside signed (the executed copy), and matching attribute names on /sign/ classified the packet as executed. The wrong label was the smaller half — the picker takes the first executed-looking document, so the packet would have outranked the real signed copy permanently and the Case would never have received the executed NDA. Now matched on signed|executed|countersign, with the packet ranked explicitly between executed and draft.

Merging this changes nothing

The step is dry until the repo variable NDA_DOC_ATTACH is set to live. It reports what it would file and writes nothing.

⚠️ The decision that comes after the merge

The launch hits Ironclad production (ironclad-launch.ts has no sandbox host), while the Case lives in the sandbox sf-demo points at. Arming this therefore moves real counterparty paper into a non-production org — and one of the two open workflows, case 00024673, has a genuine executed NDA behind it. That is a data-handling call, not a code change.

To arm: gh variable set NDA_DOC_ATTACH --body live. To disarm: delete the variable.

Detail in docs/nda-doc-attach.md.

#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
The 2026-09-04 build round for the Sept 14 pilot — four parallel streams plus an adversarial review pass, integrated and verified.
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.

Test and deploy notes (Deploy notes) are on GitHub · Open on GitHub ↗
#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
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.
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
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.
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.

Test and deploy notes (Deploy note) are on GitHub · Open on GitHub ↗
#25
lfd: one session per assistant thread, and name the thread
Joe ChiusanoLegal Front Dooropened 2026-08-27closed 2026-09-161 file, +78 / −9
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.
Closed

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 existing 8-hour TTL.
Test and deploy notes (Testing) are on GitHub · Open on GitHub ↗
#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
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).
Merged

What

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.

Why

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.

Scope

Deliberately limited to this one gate. The calc-approval, middleware and forecast operator checks are untouched — this isn't a general "Joe is an operator" change, and the list is a pinned constant precisely so a users doc write can't widen it.

Test and deploy notes (Testing) are on GitHub · Open on GitHub ↗
#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
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.
Merged

What

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.

Why now

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.

Design notes worth a reviewer's eye

  • Its own workflow, not a step in sync.yml. One owner for legalIntakeRequests writes, and an upstream sync failure must not stall the mirror — a stalled mirror means a requester never hears that their case was assigned or closed.
  • caseId is normalised to 15 characters before matching. A doc written with a 15-char id would never match an 18-char API result, and vice versa.
  • Writes are merge: true and touch only those five fields.
  • sf-rest.ts is copied verbatim from the existing jobs. That makes four copies to keep in sync (salesforce-sync, deal-desk-sync, quote-audit-sync, this). Worth factoring out into a shared package at some point — deliberately not in this PR.
  • All four SOQL queries were verified live against BU_Full.

Two things I need a decision on before Sept 8

1. sfFirstResponseAt may be structurally empty. Measured in BU_Full on 8/26: CaseComment = 29 rows org-wide; across 200 recent legal Cases, zero EmailMessage and zero FeedItem. Of 23,173 EmailMessage rows org-wide only 41 have any parent at all.

Sandbox copies routinely trim Chatter and email history, so this may not reflect prod — I can't check prod, but you can in seconds. The field has no Slack side effect, so empty is inert; the consequence is that the submitted→responded micro view would have no data behind it.

2. Closed detection misses two live terminal statuses. SF_CLOSED_STATUS_RE in functions/src/lfd-triage.ts is /^(closed|resolved|completed|done)/i, which matches only Closed (4,664 Cases). BU_Full also has Not required (1,623) and Cancelled (294), both terminal. Requesters whose case ends in either never get a close message and their ticket sits open indefinitely.

Approved (183) is ambiguous — your call whether Deal Desk treats it as terminal.

This is a Winston-side fix rather than a change to this job, so I've left it alone here.

Also

82 legal Cases are currently queue-owned (00GPd000000cznGMAQ 67, 00G2J0000040SDWUA2 15). Per the contract the owner write is held back while a queue owns the Case, so this is a main path rather than an edge case — a meaningful share of requests will show no assignee until a person picks them up.

Running it the first time

⚠️ This job has never executed anywhere, and the schedule points at prod Firestore.

workflow_dispatch carries a dry_run input that defaults to true. After merge, trigger it from the Actions tab and read the log — it reports every doc it would touch and writes nothing. Untick the box (or just let the 30-minute schedule take over) once it looks right. Schedule runs are always live; inputs.dry_run is empty on a schedule event.

Test and deploy notes (Testing) are on GitHub · Open on GitHub ↗
#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
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).
Merged

What

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.

Why

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.

FLS — resolved on the sandbox, still open on prod

Cross-object traversal enforces field permissions on Account for the integration user. Without the grant the Case query fails INVALID_FIELD, which kills the entire matter sync, not just these two fields.

  • The LFD Account AI Read permset referenced in the commit message was built in legaldemo and never followed the demo to Full — it did not exist in BU_Full.
  • ✅ BU_Full: created and assigned to (email).legaldashboard.full (profile Read Only - API). Verified both grants resolve for that user.
  • ✅ Prod: LFD_Account_AI_Read (0PSPd000000OGd7OAG) already exists, created 2026-08-21, read on both fields, and already assigned to (email).legaldashboard. Verified 2026-08-27. Nothing outstanding — an earlier version of this PR said prod was open; that was wrong.

This is the same Read grant flagged by the field probe in 8e3f1a7.

Also note

Adding fields to MatterDoc changes syncHash, so the first sync after this lands rewrites every matter document once. Expected, one-time.

Test and deploy notes (Testing) are on GitHub · Open on GitHub ↗
#21
Wiz: Upgrade @anthropic-ai/sdk to 0.91.1 (resolves 3 findings)
Wiz (bot)Dependenciesopened 2026-07-226 files, +15 / −15
<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…
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
<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…
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
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.
Merged

What

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.

Scope & behavior (per Sasha + Nick)

  • Alerts on all four tracked status fields: Commercial Legal Status, AI Privacy Legal Status, DD Status Notes, Manager Forecast Notes.
  • Includes first-time sets (empty → value); a lone - placeholder counts as empty.
  • Only fires for deals already in the dashboard — brand-new docs entering the report are skipped to avoid backfill floods.
  • Recipients: (email), (email) (overridable via LEGAL_STATUS_NOTIFY_EMAILS).
  • Best-effort: missing token/scope or any Slack error logs and no-ops — a notification failure never fails the sync.

"Real-time" = within ~30 min

deal-desk-sync is the sole writer of these fields and runs on a 30-min cron, so alerts surface on the next run. A Firestore trigger would be no faster (data doesn't change between syncs).

⚠️ Required before it runs in CI

Add the Legal bot token as a GitHub Actions secret — until then the scheduled sync logs SLACK_LFD_BOT_TOKEN is unset — skipping and sends nothing (safe no-op):

gh secret set SLACK_LFD_BOT_TOKEN

The Legal Front Door Slack app also needs the im:write scope (added + reinstalled during testing).

Test and deploy notes (Verified) are on GitHub · Open on GitHub ↗
#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
<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…
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
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 −…
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.

Test and deploy notes (Test plan) are on GitHub · Open on GitHub ↗
#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
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…
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.
Test and deploy notes (Test plan) are on GitHub · Open on GitHub ↗
#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
<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…
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
<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…
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
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…
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.
Test and deploy notes (Test plan) are on GitHub · Open on GitHub ↗
#11
feat(needs-attention): user-scoped + dismiss/undo in deal desk
Nick RaffeyDeal Deskopened 2026-05-01merged 2026-05-019 files, +260 / −54
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…
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.
Test and deploy notes (Test plan) are on GitHub · Open on GitHub ↗
#10
fix(deal-desk-sync): unwrap SF currency cells in asNumber
Nick RaffeyDeal Deskopened 2026-05-01merged 2026-05-011 file, +5 / −0
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.
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\.

Test and deploy notes (Test plan) are on GitHub · Open on GitHub ↗
#9
feat(deal-desk): restore customizable widget grid + 21 widgets
Nick RaffeyDeal Deskopened 2026-05-01merged 2026-05-016 files, +1601 / −710
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.
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.)
Test and deploy notes (Test plan) are on GitHub · Open on GitHub ↗
#8
feat: restore Legal Intake and Legal Brain views
Nick RaffeyLegal Front Dooropened 2026-05-01merged 2026-05-0126 files, +7210 / −18
Restores the LegalIntake (\"Legal Triage\") and LegalBrain features that were built on a worktree branch but never made it onto \main\.
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
Test and deploy notes (Deploy ramifications, Test plan) are on GitHub · Open on GitHub ↗
#7
feat(deal-desk): replace inline expand with right-side popup
Nick RaffeyDeal Deskopened 2026-05-01merged 2026-05-012 files, +356 / −136
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…
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.
Test and deploy notes (Test plan) are on GitHub · Open on GitHub ↗
#6
revert: deal-desk widget grid + currency fix (PR #5)
Nick RaffeyDeal Deskopened 2026-05-01merged 2026-05-017 files, +700 / −1607
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…
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
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…
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
Test and deploy notes (Test plan) are on GitHub · Open on GitHub ↗
#4
chore: bump version 0.1.0 → 0.2.0
Nick RaffeyPlatformopened 2026-05-012 files, +3 / −3
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).
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
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.
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).

Test and deploy notes (Test plan) are on GitHub · Open on GitHub ↗
#2
fix(chat): allow Cloud Run in CSP, rename env var to match code
Nick RaffeyPlatformopened 2026-05-012 files, +5 / −3
The chat is blocked in production for two reasons, both fixed here:
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.
  1. .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
Test and deploy notes (Test plan) are on GitHub · Open on GitHub ↗
#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
This PR now contains two logical changes that landed on the same branch. Sorry for the squash — describing both for review.
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/channel widgets → top askers/responders/customers → thread list → detail modal.
  • query_legal_questions tool wired into chatProxy so Margot can answer "has this come up before?" with a Slack permalink.

Run order to populate from scratch:

  1. Provision a Slack User OAuth Token (xoxp-) with channels:history, groups:history, channels:read, groups:read, users:read.
  2. cd jobs/slack-legal-sync && cp .env.example .env, set SLACK_BOT_TOKEN + ANTHROPIC_API_KEY.
  3. npm install && npm run backfill && npm run classify.

Already done in production: 1108 threads classified (4 channels × 3 years).

Test and deploy notes (Test plan) are on GitHub · Open on GitHub ↗