Email is a perfectly reasonable way to receive feedback from an app. It is also very easy for an email to become a dead end.

A useful bug report arrives beside receipts, newsletters, and everything else in an inbox. I read it, intend to return to it, and eventually copy part of it into GitHub. By then the app version and device context may be gone. My reply lives in email, investigation notes live somewhere else, and there is no durable state tying the report to the eventual issue.

I wanted a tighter loop:

text
Settings → Send Feedback → accepted reference
                  correct per-app Slack channel
                  one thread for the entire report
                       verified GitHub issue

The form turned out to be the least interesting part. The real work was deciding what “sent” means, making retries safe, keeping Slack out of the mobile trust boundary, surviving a Slack outage, and preventing one report from fragmenting across channels.

This is part two of Building Indie Ops. Part one covers the Slack and provider architecture. Part three follows the verified issue through Xcode Cloud and private TestFlight.

The Rules I Started With

Before writing the UI, I wrote down the properties I wanted from the system:

  1. The app talks only to its app-specific backend. A Slack token or portfolio-wide ingest token must never appear in an application binary.
  2. An accepted response means the feedback is durable. It does not merely mean Slack returned HTTP 200.
  3. Slack is the notification and triage surface, not the database.
  4. Retrying an unchanged submission must not create a second record or a second Slack parent.
  5. Bugs, ideas, and general feedback belong in different channels, but each report must remain one thread.
  6. Analytics can record that feedback was opened or submitted. It cannot receive the message, email address, attachment, or installation identifier.

Those rules produce a little more architecture than posting directly from the app to Slack, but they remove several dangerous shortcuts.

The Two-Worker Boundary

The deployed AeroNautical pilot uses an app-specific public Worker and the shared Indie Ops Worker:

text
iOS app
   │ POST /v1/feedback
App API Worker
   │ validate + rate limit + hash installation ID
   │ private service binding + app-specific server token
Indie Ops Worker
   ├── insert feedback into D1
   └── enqueue feedback ID
      Cloudflare Queue
      Slack category parent
            ├── 👀 triage
            ├── investigation
            ├── verified GitHub issue
            └── ✅ complete

The public Worker owns the mobile trust boundary. It validates a small request, applies a dedicated per-installation rate limit, hashes the installation UUID, and forwards a privacy-safe payload through a private service binding.

The shared Worker owns portfolio-wide storage, route lookup, Slack credentials, and queue consumption. The two Workers have separate deployment lifecycles. An app can keep its existing API domain and rate limits without learning how the rest of the portfolio is wired.

The first pilot does not require App Attest to be useful. The app-specific Worker is the public boundary, and Indie Ops accepts only a dedicated server credential from that Worker. App Attest and DeviceCheck remain hardening options for the public surface rather than excuses to delay durable feedback capture.

Keeping the iOS Feature Small

The shared SwiftUI feature asks for only what is needed to act on a report:

swift
enum FeedbackCategory: String, CaseIterable, Codable {
  case feedback
  case bug
  case idea
}

struct FeedbackContext: Encodable, Equatable {
  let appVersion: String
  let buildNumber: String
  let osVersion: String
  let locale: String
  let deviceFamily: String
}

struct FeedbackSubmission: Encodable, Equatable {
  let idempotencyKey: UUID
  let category: FeedbackCategory
  let message: String
  let replyEmail: String?
  let context: FeedbackContext
}

The app supplies the version, build, iOS version, locale, and broad device family. It does not attach location, browsing history, logs, screenshots, analytics IDs, subscription IDs, or account identifiers by default.

Screenshots and diagnostics are a separate consented path. The current pilot can accept one consented JPEG through a short-lived private R2 URL. Attachments have stricter size and content limits and a shorter retention period than the text record. The form should say what is attached and how long it is retained; “diagnostics may be attached” is too vague to be meaningful privacy copy.

The host app injects the API URL, anonymous installation ID, diagnostic context, analytics adapter, navigation, and alert presentation. The form does not know where the product stores global configuration, and it can later move into a shared Swift package without taking the host app's networking and navigation assumptions with it.

Accepted Means Stored

The shared Worker writes the feedback transaction to D1 before attempting Slack delivery. A simplified table looks like this:

text
feedback
  id
  app_slug
  idempotency_key
  category
  message
  reply_email
  installation_hash
  app_version
  build_number
  os_version
  locale
  device_family
  status
  created_at
  slack_channel_id
  slack_thread_ts
  delivery_status
  delivery_attempts
  last_delivery_error

The database enforces a unique constraint on (app_slug, idempotency_key). This turns a retry after a network timeout into a lookup of the original record rather than a second insert.

The Worker returns 202 Accepted and a feedback ID as soon as the record is durable. It does not wait for RevenueCat enrichment or Slack. The app can show a concrete receipt without pretending that a human has already read the report.

text
App → 202 Accepted + feedback ID
     ├── user sees “Thanks, your report was received”
     └── queue continues delivery and enrichment

This is the most important semantic change from email. A Slack outage does not turn a valid user action into a failed submission.

The Retry Problem Exists on Both Sides

The app can time out after the Worker committed the record but before the response reached the device. If pressing Send again creates a new identifier, a harmless network failure becomes two reports.

The view model therefore reuses the idempotency key while the content remains unchanged:

swift
let fingerprint = [
  category.rawValue,
  normalizedMessage,
  normalizedReplyEmail ?? ""
].joined(separator: "\u{001F}")

if pendingSubmissionFingerprint != fingerprint {
  pendingSubmissionFingerprint = fingerprint
  pendingIdempotencyKey = UUID()
}

let key = pendingIdempotencyKey!

Editing the category, message, or reply address creates a new fingerprint and therefore a new submission. The key is cleared only after a successful acceptance.

The queue consumer has its own retry problem. It can be redelivered after Slack accepted a request but before D1 recorded the response. Each parent message therefore uses a deterministic Slack client_msg_id. A retry can reuse the same identity instead of creating another top-level message.

The queue consumer also handles a provider delay that is specific to the feedback flow. The record is initially stored with an unknown customer context. Enrichment is attempted within a bounded deadline. The consumer can defer a pending record for 15 seconds at a time; after two minutes it atomically marks the enrichment as timed out and delivers the already-stored unknown context. RevenueCat latency cannot make a user report disappear.

The Public Worker Is a Narrow Gate

The app-facing Worker does five things:

  1. Validate the anonymous installation UUID.
  2. Apply a per-installation rate limit.
  3. Parse a strict JSON body with known fields and categories.
  4. Hash the installation ID before forwarding it.
  5. Call Indie Ops with an app-specific server credential.

The portfolio-wide ingest token is never shipped in the app. The public edge uses a dedicated feedback credential and the shared Worker authenticates it again. Each trust boundary validates its own input, even when the caller is another service I control.

The installation hash is pseudonymous, not magical anonymity. Its purpose is to support rate limiting and duplicate investigation without forwarding the raw installation UUID into the shared system.

The request schema is deliberately restrictive:

typescript
const feedbackSchema = z
  .object({
    contract_version: z.literal(1),
    idempotency_key: z.string().uuid(),
    category: z.enum(["feedback", "bug", "idea"]),
    message: z.string().trim().min(4).max(2_000),
    reply_email: z.string().trim().email().max(254).nullable().optional(),
    context: z
      .object({
        app_version: z.string().trim().min(1).max(40),
        build_number: z.string().trim().min(1).max(40),
        os_version: z.string().trim().min(1).max(80),
        locale: z.string().trim().min(2).max(80),
        device_family: z.string().trim().min(1).max(80),
      })
      .strict(),
  })
  .strict();

Unknown fields are rejected. Message size is limited. External errors stay generic while the internal rejection reason remains available for operations.

Routing by Category, Not by Human Memory

Once the record is durable, the queue consumer maps the category to a topic:

CategorySlack topic
bugbug-reports
ideaideas
feedbackuser-feedback

It then resolves the active (app, topic) route and posts a compact parent message:

text
New idea · AeroNautical
Let me save a group of airports.

Version 4.2 (310) · iOS 26.0 · en-IN
Feedback ID: 019...
Reply email: [email protected]

The message is intentionally compact. It contains enough context to triage without turning Slack into a second database or exposing more personal data than the person chose to provide.

One report creates one Slack parent. Investigation notes, follow-up questions, the GitHub issue link, and completion status are replies to that parent. I use 👀 when triage starts and ✅ when the handoff or resolution is complete.

The category routing corrected a real organizational mistake. Initially, every report went into user-feedback, while the bug-reports channel also received GitHub issue notifications. One report could then appear as a feedback message, a separate GitHub top-level post, and a discussion somewhere else. The category channel and originating thread now carry the entire story.

For the same reason, I do not subscribe a category channel to broad GitHub issue-created notifications. The issue should be created deliberately and its link independently verified in the originating Slack thread.

User Text Is Untrusted in Slack Too

Slack formatting is another input boundary. A feedback message can contain mentions, links, control characters, or text that looks like an operator command. The Worker escapes or sanitizes the user-supplied text before composing the Slack block and does not let the message choose its destination.

The app, category, and channel come from validated server-side state. The message is content, not routing data.

This sounds obvious until the first report contains a string that begins like a Slack mention or an accidental code block. The safe default is to make user text boring to Slack.

GitHub Creation Needs Independent Verification

Creating an issue is not the same as proving that the issue exists in the correct repository. The originating app determines the repository and label. The Worker or coding agent uses that mapping, creates the issue, and then verifies the returned repository, issue number, and URL before posting the link back into the thread.

The link is part of the feedback record, not just a message someone happened to paste into Slack. That gives the report a durable handoff:

text
feedback.id
   ├── Slack channel + thread
   ├── category + app context
   ├── GitHub repository + issue number
   └── status: new → triaged → closed

The first release only needs reliable capture and notification. Richer Slack actions can later mark a report triaged or closed, create an issue with the correct bug or feature label, and update D1 through the Worker. The state must remain authoritative and auditable even when the action started in Slack.

Privacy Is Part of the Data Model

Analytics may record feedback_opened, feedback_submitted, feedback_succeeded, and feedback_failed, plus the category. It must not receive the message body, email address, installation identifier, or attachment contents.

The same rule applies to ordinary request logs. A useful operational metric is “feedback delivery failed after three attempts.” It is not useful to log the user's entire report beside that metric.

Reply email is optional and used only for that submission. The user can ask for a reply without turning the app into an account system. Retention for feedback text, contact information, and future attachments should be defined before rollout, not retrofitted after the first sensitive report arrives.

What Failure Looks Like

The system has explicit states for the awkward cases:

  • The app times out after the record is committed: retry the same idempotency key.
  • The app sends a malformed body: return a generic validation error without creating a record.
  • Slack is unavailable: keep the D1 record and retry the queue delivery.
  • RevenueCat enrichment is slow: deliver the unknown context after the bounded deadline.
  • Slack accepts the parent but the Worker loses the response: deterministic client_msg_id prevents a duplicate.
  • No category route exists: keep the record undelivered and replay it after configuration is repaired.
  • GitHub issue creation fails: keep the feedback thread open; do not claim that triage is complete.

That is more work than URLSession plus an email address. It is also the difference between a contact form and an operational feedback system.

The Result

The user sees a small form and a clear accepted state. The app contains no Slack credential. Indie Ops receives a validated, deduplicated record. Slack gets one category-correct parent. The investigation stays in one thread. GitHub receives a deliberately created and independently verified issue.

The parts are replaceable, but the invariants are not:

Store first. Route from server-side state. Retry with identity. Keep one report in one thread.

That gives me a feedback loop I can operate across several apps without asking each app to know anything about Slack. The final part applies the same ideas to shipping: from a verified pull request to private TestFlight with Xcode Cloud.