FLT 75 REVIEW ROUND1

Final Review

Critical — FounderIntake.Host/IntakePipeline.cs:96, FounderIntake.Host/ConversationResponder.cs:137
Confirm tokens are bound to the confirmation event’s fresh request id, not the original request that issued the token. A real CONFIRM <token> arrives as a new job in Received, so confirmation fails before ticket creation.
Fix: persist one active request/envelope per conversation thread, reuse that request id on later turns, and confirm with the current message actor against the original request owner.

Critical — FounderIntake.Host/ConversationResponder.cs:137, FounderIntake.Host/ConversationResponder.cs:234
The hardened confirm gate is optional. If Envelope or RequestStateMachine is missing, the code falls through to ticket creation on confirm|approve|go ahead with no token.
Fix: in hardened mode, require envelope + state machine + token-shaped command; otherwise reject safely and mark the job handled/failed without Jira.

High — FounderIntake.Host/IntakeLeaderLease.cs:19, FounderIntake.Host/IntakeLeaderLease.cs:33, FounderIntake.Host/Program.cs:117
Leader election is not a real cross-process lease and followers never retry acquisition. TryAcquire is check-then-write, Renew has no generation/CAS, and non-leaders omit the pump/responder forever after startup.
Fix: use SQLite or an OS file lock with a lease token/epoch and conditional renew; run a follower acquisition loop that starts/stops leader services on lease changes.

High — FounderIntake.Core/ReplyOutbox.cs:20, FounderIntake.Core/ReplyOutbox.cs:62, FounderIntake.Host/OutboxPump.cs:57
The sanitizer fails open when ReplyOutbox is constructed without one, and delivery strips the envelope so send-time thread binding cannot be rechecked.
Fix: make the sanitizer non-nullable, persist envelope/thread binding fields in the outbox row, and re-run inspection in OutboxPump before SendAsync.

High — FounderIntake.Host/ConversationResponder.cs:81
Every assistant response is advanced to AwaitingConfirm and gets a live token, even if it is only a clarifying question. That rewards “state reached” rather than “ready to file.”
Fix: only transition to AwaitingConfirm from an explicit ready-to-file decision, ideally a structured model/control output validated by host code.

High — FounderIntake.Core/EgressSanitizer.cs:37, FounderIntake.Core/EgressSanitizer.cs:46
The sanitizer misses bare unapproved URLs, space-padded markdown URLs, IPv6/short loopback/link-local forms, inline Jira footer fragments, and some fleet names.
Fix: parse all URLs from body text with Uri.TryCreate, classify IP ranges, allowlist hosts globally, and add tests for bare URLs, [x]( https://evil ), ::1, 169.254.169.254, inline [Jira FLT-…], Jenny, and Cody.

Medium — FounderIntake.Host/ConversationResponder.cs:176, FounderIntake.Core/IdempotencyStore.cs:22, FounderIntake.Host/IntakePipeline.cs:100
Idempotency is checked after side effects and the confirm job’s envelope key is based on the confirmation text, not the original ask. Same-thread SQLite reservation reduces duplicate Jira risk, but the idempotency layer itself is not the durable create-before-effect guard it claims to be.
Fix: compute/store the idempotency key on the original thread request, reserve it before Jira create with a unique durable row, then fill in the ticket key.

Medium — FounderIntake.Host/ConversationResponder.cs:137, FounderIntake.Core/RequestStateMachine.cs:42
Duplicate confirms and malformed token lengths throw before the idempotent already-confirmed response path.
Fix: check existing thread completion before token redemption when appropriate, make same-request redemption idempotent, and length-check before FixedTimeEquals.

Medium — FounderIntake.Host/ConversationResponder.cs:91, FounderIntake.Core/ReplyOutbox.cs:62
A held outbound reply returns false, but the responder treats it like success and marks the job done. The requester gets silence and operators only get a sanitizer log.
Fix: return an enqueue result enum: inserted, duplicate, held. Mark held jobs distinctly and alert/page.

Medium — FounderIntake.Host/Program.cs:32, FounderIntake.Host/Program.cs:35, FounderIntake.Host/Adapters.cs:15
RequesterDirectory is registered but unused; identity/display names still come from config mapping.
Fix: wire the directory into ISourceIdentityResolver; authorize by stable Slack/email identity, but derive display names from the verified profile source.

Low — FounderIntake.Host/Adapters.cs:55, FounderIntake.Host/ConversationResponder.cs:226
Dry-send logs and Jira transcript comments can include confirmation tokens and held-sensitive text.
Fix: redact CONFIRM <token> and log only key/hash/length/rule metadata for outbound body text.

Kerr findings: FounderIntake.Tests/TrustHardeningTests.cs:12 and :46 reward proxy counts/state transitions while the real goals are no leak/no unauthorized ticket; cheapest game is adding narrow regexes and direct state-machine tests while bare URLs, per-event confirm binding, and fallback confirms still fail. Fix with end-to-end tests: real thread asks, receives token only when ready, confirms from same actor, creates exactly one Jira, and blocks bare/internal leak variants.

Disagreements

Reviewer C says a different requester can redeem someone else’s token via race. Dropped as stated: the identity check at RequestStateMachine.cs:44 still blocks a different identity. The real race is double redemption or bad error handling, not cross-user redemption.

Reviewer A/B say idempotency can directly duplicate Jira. Qualified: SetThreadTicket reserves the same thread before Jira, so same-thread duplicates are mitigated when all hosts share the same SQLite DB. The idempotency store is still incorrectly ordered, file-based, and keyed to the wrong event.

Reviewer C rates RequesterDirectory scan performance as High. Downgraded: O(N) profile scanning is real, but the security issue is that the directory is not wired at all; performance is secondary here.

Dropped Findings

  • Direct ICorrelatedThreadReplyWriter bypass as a production path: no current host path sends directly except through OutboxPump; kept the stronger pump/no-envelope issue instead.
  • AgentMail ThreadKey is message id as a separate finding: true in the envelope code, but it is part of the broader per-event request binding bug.
  • Regex compilation performance: valid nit, not material for this security review.
  • JSON file rewrite disk thrashing: valid scalability concern, but lower signal than the atomicity/lease issues.
  • Result-pattern recommendation for state transitions: style-level; the actionable bug is unhandled duplicate/malformed confirm behavior.
  • Idempotency delimiter collision: theoretical and hash-input-only; not a practical finding compared with wrong key lifetime and reservation ordering.
  • ALTER TABLE swallowing all SQLite error code 1: real maintenance concern, but not trust-hardening critical enough for the final list.

Jira FLT-75 · Vikunja · BOARD.md