Skip to content

FLT-75 P0 — Intake trust hardening (council-approved architecture)

Context: the intake bot failed its dry-run with external user Allyson in #work-wingman-intake (2026-07-22). Observed: (1) addressed Allyson as "Andrew" in 5/9 replies; (2) unrelated answers posted into her thread (no request→thread binding); (3) four duplicate Jira tickets WING-90..93 for one request; (4) promised confirm-before-create gate bypassed; (5) internal fleet board footer leaked into a user-facing reply ([Jira FLT-3](...) · [Vikunja](http://127.0.0.1:3456/...) · [BOARD.md](docs/BOARD.md)); (6) stale knowledge + invented assumptions; (7) duplicate replies in the same second (concurrent hosts, no lock).

Governing principle (LLM council verdict, 2026-07-23): LLM proposes, C# code disposes. Every outbound message and every side effect passes through deterministic C# enforcement. None of the guarantees below may live only in prompt text.

Implement ALL of the following in this repo (FounderIntake.slnx). C# only. Keep the existing LocalJsonStore/JSON-file persistence style (no EF, no SQLite, no Redis). Follow existing code style. Every item gets unit tests in FounderIntake.Tests. Existing tests must keep passing (dotnet test).

1. EgressSanitizer (FounderIntake.Core/EgressSanitizer.cs) — FAIL CLOSED

A mandatory filter applied to EVERY outbound reply body (Slack and AgentMail) at the single choke point where replies leave the system (ReplyOutbox enqueue or OutboxPump send — pick the narrowest single point and enforce there so no code path can skip it).

Block rules (case-insensitive, unit-tested regex): - URLs to localhost, 127.0.0.1, 0.0.0.0, *.local, or RFC-1918 hosts (10.x, 192.168.x, 172.16-31.x) - Vikunja links (:3456), BOARD.md, docs/BOARD.md, repo-relative markdown paths - The fleet board footer pattern: a line containing Jira FLT- or starting with [Jira - Worktree/session IDs matching WT-[0-9a-f]{4} and wt/WT- - Harness self-names: Clahadore, Cedric, Gronktayvius, caveman, usage-governor, gov: role tags - Any markdown link whose host is NOT on the allowlist. Allowlist (config-extensible via appsettings): workwingman.atlassian.net, workwingman.slack.com, docs.google.com, drive.google.com, github.com, learn.microsoft.com, aws.amazon.com, azure.microsoft.com, docs.aws.amazon.com.

Behavior on match: DO NOT auto-strip-and-send. The message is HELD: persisted to a held-outbound JSON store (existing store style) with the matched rule names, and a log line emitted. Nothing is sent. Result type: SanitizeResult { Clean | Held } with matched-rule details.

2. Confirm gate as a code-level state machine (FounderIntake.Core/RequestStateMachine.cs)

States: Received → Drafting → PrivateDraftReady → AwaitingConfirm → Executing → Done | Rejected. - Persist state per request in a JSON store keyed by RequestId. - Jira ticket creation (JiraAdapter create path) MUST require state == AwaitingConfirm AND a confirmation token minted by the state machine when the draft was presented. The token is only redeemable by a confirmation message from the original requester's verified channel identity (Slack user id of the thread author / AgentMail sender address). A confirm from anyone else (including the workspace owner) is rejected and logged. - Illegal transitions throw; the pipeline catches, logs, and holds — never proceeds. - Wire the existing confirm-gate flow in IntakePipeline/ConversationResponder through this machine. Delete/replace any prompt-text-only enforcement.

3. Idempotency for ticket creation (FounderIntake.Core/IdempotencyStore.cs)

Key = lowercase hex SHA256(requesterProfileId + "|" + threadKey + "|" + normalizedRequestText) where normalizedRequestText = trim, collapse whitespace, lowercase. - Insert-or-get semantics against a JSON store with an in-process lock (single-writer host assumption, see item 5) — first writer wins, later callers get the existing ticket key back. - JiraAdapter create path checks the store BEFORE creating; on hit returns the existing WING-xx key and the reply says "already logged as " instead of filing a new ticket.

4. Request context envelope + thread binding (FounderIntake.Core/RequestEnvelope.cs)

Record: RequestEnvelope { RequestId, Surface (Slack|AgentMail), ThreadKey, RequesterProfileId, RequesterDisplayName, Mode, IdempotencyKey }. - ThreadKey: Slack = channelId + ":" + thread_ts (root ts if unthreaded); AgentMail = Message-ID. - The envelope is created once at intake and flows through the pipeline; every outbound reply must carry the envelope's ThreadKey. The outbox REJECTS (holds + logs) any reply whose destination thread does not equal the envelope ThreadKey — cross-thread posting becomes structurally impossible. - RequesterDisplayName comes from the Slack message author's user profile (fetch via Slack API users.info, cache it) or the AgentMail From name — NEVER from harness config or host identity. All reply templates that greet by name must use the envelope's RequesterDisplayName. This kills the "Hi Andrew" bug: a message from Allyson is answered "Allyson".

5. Single-writer intake lock (FounderIntake.Host/IntakeLeaderLease.cs)

Lease file in the shared data directory: {host, pid, heartbeatUtc} JSON, written atomically (temp + File.Move). On startup the host tries to acquire; a live lease (heartbeat < 90s old) from another host/pid means this instance runs in enqueue-only/standby mode and does NOT process the queue or send replies. Heartbeat every 30s. Expired lease may be reclaimed. Unit-test acquire/renew/reclaim logic with an abstracted clock.

6. Requester directory (FounderIntake.Core/RequesterDirectory.cs) — slim profile, NOT harness memory

JSON store users/<profileId>/profile.json: { ProfileId, SlackUserId, Email, JiraAccountId, DisplayName, CreatedUtc, VerifiedLinks[] }. - Resolution ONLY by verified channel identifiers (Slack user id, authenticated sender email) — never display-name matching. - Unknown sender → create provisional profile from the channel identifier. - Cross-surface link (e.g., attach an email to a Slack-origin profile) requires explicit verification (a token echoed over the surface being linked); silent merges on matching display name or unverified email are forbidden (impersonation vector). Implement the store + resolution + a RequestLink/ConfirmLink token flow; wiring the actual verification message can be minimal.

Acceptance (replay-Allyson test, add as integration-style tests where feasible)

  • A Slack message from user U_ALLYSON is greeted with her display name, never the host owner's.
  • Replies land only in the origin ThreadKey; a cross-thread reply attempt is held.
  • The same request text twice → exactly one ticket; second attempt returns the first ticket key.
  • No Jira ticket is created without state AwaitingConfirm + requester-issued confirm token.
  • An outbound body containing 127.0.0.1, BOARD.md, or a [Jira FLT- footer is held, not sent.
  • Two hosts starting concurrently → one leader, one standby.

Ground rules

  • dotnet build clean, dotnet test green before you finish.
  • Do not touch .claude/worktrees/** (stale embedded copy).
  • Do not commit. Leave changes uncommitted for review.
  • Summarize what you changed per file at the end.