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
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 buildclean,dotnet testgreen 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.