14 min read

How We Built Charlie, Part 4: Routing Is Ownership

How Charlie prevents duplicate or orphaned work by choosing the right ownership outcome for each Signal.

Pastel pink geometric poolside structures with a tiled arch, curved wall, palm trees, and a pale sun

How We Built Charlie continues from Chapter 3: an enriched Signal now needs an accountable ownership outcome.

Three comments land on a release issue within a minute. The first asks Charlie to investigate a rollback that left one service on the wrong revision. The second adds a useful constraint: the mismatch only appears in one repository. The third is an automated status update from a deployment bot.

There should be one investigation. The explicit request should establish an owner. The clarification should join that active work. The automated status should remain available as source context when useful, but it should not create another Task merely because it arrived nearby.

This is the routing problem: choose the appropriate ownership outcome for a Signal. It may start a new Task, append to an existing Task’s mailbox, activate one or more repository-defined daemon roles, or produce no work. The decision assigns responsibility. A bad outcome can create competing actors, hide a correction from the active owner, wake an unrelated specialist, or spend compute on noise.

Charlie treats routing as a policy decision over durable identities, current lifecycle state, repository context, and activation rules. The contracts and traces below are simplified. They describe the important boundaries without exposing private identifiers, internal topology, or every field carried through the implementation.

Decision diagram showing an enriched Signal evaluated for relevance, active ownership, daemon policy, and actionability before becoming a mailbox append, new Task, one or more scoped daemon routes, or no work

Routing assigns an ownership class. Normal routing selects one fallback outcome; a successful daemon-policy class may activate more than one scoped role.

A Signal needs more than a delivery ID

By the time routing begins, Charlie has already authenticated and normalized the provider event. The Signal carries enough information to recover the source material and reason about the collaboration surface. It has its own ID and request correlation, but those values alone do not say which work it belongs to.

One important field is charlieUri: a canonical, provider-aware address for the resource or thread represented by the Signal. A GitHub issue comment, Linear comment, or Slack thread can have very different provider payloads while still yielding a structured address that routing code can inspect consistently.

A simplified Signal fragment looks like this:

{
  id: "signal_example_followup",
  requestId: "request_example_release",
  charlieUri:
    "charlie://customer_example/v1/github/owner/example/repo/release/issue/84/comment/203",
  charlieUriData: {
    kind: "github/issue-comment",
    provider: "github",
    owner: "example",
    repo: "release",
    issueNumber: 84,
    commentId: 203
  },
  sender: { id: "user_example", isBot: false },
  eventType: "issue_comment",
  eventAction: "created"
}

The full Signal also carries customer and environment scope plus optional event metadata. The original source payload remains recoverable from a deterministic storage path derived from the environment and Signal ID. The Task receives a smaller fragment so later execution can retain source identity without copying every ingress field into the lifecycle record.

Several identities need to remain distinct:

IdentityRouting use
Provider deliveryTrace receipt and recognize provider redelivery
Signal IDIdentify one enriched routing input
charlieUriAddress the collaboration resource or thread
Request correlationConnect processing and telemetry for one request path
Task IDIdentify the durable owner of an engineering objective
Coordination keyConverge eligible daemon activations on one active owner
Effect idempotency keyMake one scheduler operation converge when the same routing attempt repeats

Two messages in one Slack thread are separate Signals. They may still belong to one active Task. A redelivered provider callback may repeat one Signal’s logical routing attempt and should not create a second scheduler effect. A second explicit request in the same issue may deserve a new Task if it establishes a different objective. Thread identity is evidence, not ownership by itself.

Ownership outcomes assign different kinds of responsibility

The output of routing is easier to review when each route has a clear ownership meaning.

RouteOwnershipExample
New TaskEstablish a new durable owner for an objective“Find why the release deployed mixed revisions.”
Existing Task mailboxAdd relevant input to work that still owns the objective“The mismatch only occurs in the API repo.”
Daemon routesActivate scoped repo roles under policyA matching event reaches its scoped review and release roles
No-op / dropDecline to create workAn unknown bot update, invalid event, own event, or duplicate

These outcomes carry different responsibility. Creating a Task adds another lifecycle and another potential actor. A mailbox append preserves the current owner. Daemon routing applies predeclared role and activation contracts, and policy can select several scoped roles for one Signal. A no-op records that the right outcome is no new work.

Normal routing chooses at most one fallback: an existing Task, a new Task, or no work. The successful daemon-route class is the narrower exception because one Signal may legitimately match more than one independent scoped role.

The release issue shows why the distinction matters. If all three comments become Tasks, Charlie may produce competing explanations or duplicate patches. If the clarification is dropped, the active investigation can miss the fastest path to the fault. If the deployment bot update wakes a general-purpose agent, proximity has been mistaken for intent.

Existing ownership is a lifecycle question

Routing compares the Signal with active non-daemon Tasks in the customer scope and uses the available context to make the ownership decision. It still has to determine whether that Task can accept more input.

For an active target, the orchestrator renders execution content and asks the scheduler to append a durable mailbox message. The scheduler operation includes an idempotency key so a repeated routing attempt converges at that append boundary.

sendNewMessage({
  customerId,
  taskIds: [candidateTaskId],
  content: renderedSignal,
  idempotencyKey: mailboxKey,
});

This example omits retry configuration and event metadata. The important point is that routing does not mutate an executor transcript directly. It writes to the scheduler-owned mailbox of a durable Task. The executor can retrieve the message at a later checkpoint, as described in Chapter 2.

There is a race between choosing the candidate and appending the message. The Task may finish in that interval. Charlie handles that race carefully because a failed append does not automatically authorize replacement work.

  • If the scheduler says the target is already terminal, routing can schedule a fallback Task with the related source context.
  • If the scheduler says the target is missing, the orchestrator reconciles by reading current Task state.
  • If reconciliation proves that the Task is terminal or genuinely absent, fallback scheduling is allowed.
  • If the Task still appears non-terminal, or state cannot be reconciled, the routing attempt is retried rather than creating a possible duplicate.

That last case prevents a subtle failure. A transient lookup disagreement must not become two owners. “Mailbox append returned not found” is an observation about one operation, not proof that the previous owner is gone.

Timeline showing an explicit request creating Task T1, GitHub, Linear, and Slack follow-ups appending to its mailbox while active, an unrelated status becoming a no-op, and a late relevant request falling back to Task T2 only after terminal state is confirmed

Signals can arrive through several surfaces while one Task remains the owner. A new owner is created only when policy calls for new work or the previous target is confirmed terminal.

Cross-surface continuity does not mean thread equivalence

Charlie can receive related input through GitHub, Linear, and Slack. The product experience should still feel like one investigation when the objective is one investigation.

Suppose the release request begins in GitHub. A program manager adds the affected customer tier in Linear. An engineer pastes a reproduction command into the connected Slack thread. If routing has evidence that all three inputs change the same active objective, each can become a mailbox message for the same Task.

The Task supplies continuity across surfaces. The providers still retain separate identities, permissions, source artifacts, and reply locations. Routing should not flatten them into one synthetic conversation. It should preserve where each fact came from while choosing one owner.

The reverse also matters. Two comments in one GitHub issue can request separate work. “Investigate the mixed revision” and “replace the deployment platform next quarter” share a thread but not necessarily an objective, urgency, or completion condition. A durable owner should be narrow enough that its result can answer what it was asked to do.

This is why charlieUri is necessary but insufficient. It gives routing a stable address for the source. Semantic classification, explicit activation, repository context, active ownership, and policy decide what that source means for work.

Repository and agent resolution constrain the route

Many engineering requests need a repository before execution can begin. Routing may infer it from the Signal’s canonical resource, provider metadata, referenced artifacts, or configured customer context. The selected repository then participates in agent and daemon resolution.

A normal Charlie Task uses the agent label configured for that mode. A daemon route also resolves the repository-defined daemon file and its agent label. These values are part of the schedule request because they determine which bounded role will execute the Task.

Conceptually:

const repo = inferRepository(signal, routingContext);
const target = resolveAgentOrDaemon(policy, repo);

scheduleTask({
  customerId,
  content: renderForExecution(signal),
  repo,
  agentLabel: target.agentLabel,
  daemonFile: target.daemonFile,
  signal: toSignalFragment(signal),
  idempotencyKey: scheduleKey,
});

Repository inference is not an excuse to guess silently. A route can decline work when required context is unavailable or ambiguous. Starting in the wrong repository creates a convincingly active Task with the wrong authority, which is worse than an explicit no-op or request for clarification.

Agent labels are also routing constraints rather than decorative metadata. They select an execution contract. A general engineering agent and a release-triage daemon may use overlapping tools, but they do not own the same class of events.

Daemon wakes use coordination, not immortal processes

A daemon in Charlie is a persistent role definition with bounded activations. It is not a process that runs forever. When a matching Signal arrives, routing can schedule a new daemon Task or append the activation to the mailbox of an existing coordination owner.

The scheduler’s coordination operation makes that decision atomically within its own boundary:

scheduleOrAppendByCoordinationKey({
  customerId,
  coordination: {
    key: 'repo-defined-role-and-scope',
    activation: renderedSignal,
  },
  scheduleRequest,
  idempotencyKey: coordinationKey,
});

If an active owner exists for the coordination key, the activation is appended and the existing Task remains responsible. If none exists, the scheduler creates a new owner. If the previous owner has become terminal, a later activation can establish another bounded Task.

This supports product experiences such as a repository-defined review daemon receiving several relevant CI or review events without starting one Task per callback. It also keeps unrelated events out. A daemon should wake because its declared activation policy matches, not because a webhook happened to mention a familiar repository.

Coordination keys and scheduler idempotency keys solve different problems. The coordination key asks, “which active Task owns this class of activation?” The operation idempotency key asks, “has this exact scheduling or append effect already been applied?” Keeping them separate prevents one source redelivery from becoming another activation while still allowing later, distinct activations to reach the same owner.

Idempotency protects effects, not meaning

Routing deals with two kinds of duplication.

Delivery duplication occurs when a provider, queue, or caller repeats the same logical attempt. Stable operation keys can make the scheduler append or schedule converge. This is a mechanical property at a defined boundary.

Semantic duplication occurs when distinct Signals ask for the same work. Three engineers may mention Charlie separately after seeing the same release failure. Their deliveries, Signal IDs, and message text differ. An idempotency key cannot decide that they mean one investigation.

Semantic deduplication depends on ownership policy: canonical resource identity, active Tasks, objective similarity, explicit references, sender intent, daemon coordination, and current lifecycle. It is probabilistic or policy-driven where meaning is involved, so the system should avoid claiming more certainty than it has.

The strongest accurate statement is narrower:

  • Repeated attempts can converge at specific scheduler operations.
  • Eligible daemon activations can converge on an active coordination owner.
  • Relevant follow-ups can join an active Task’s mailbox.
  • Distinct external effects remain subject to their own idempotency or reconciliation rules.
  • Charlie does not provide end-to-end exactly-once execution.

Contract diagram separating signal identity, semantic ownership decisions, and scheduler effect keys, with active and terminal target outcomes

Identity makes the source addressable, policy assigns meaning, and operation keys make one scheduler effect converge. No one key provides global deduplication.

No-op is a successful routing outcome

Systems that can act need a disciplined way not to act. The no-op route handles events that are unsupported, non-actionable, irrelevant, self-generated, duplicate at an already-protected boundary, or disallowed by policy.

For the release issue, the deployment bot’s status comment might still be recoverable in the provider thread. It does not need a Task simply to preserve visibility. If a human later asks Charlie to interpret that status, the explicit request can create or update owned work with the bot comment as context.

Treating no-op as a first-class outcome has operational benefits:

  • It keeps automated chatter from consuming agent attention.
  • It reduces feedback loops from Charlie’s own provider writes.
  • It makes unsupported cases observable without pretending they are failures.
  • It gives policy rollout a safe default when confidence is low.

Dropping work should still be explainable through internal decision evidence. “No Task was created” is useful only when operators can distinguish policy, quota, unsupported input, and retry exhaustion where appropriate.

Shadow classification makes policy changes reviewable

Routing policy changes can alter ownership at scale. A small classifier adjustment may turn thousands of harmless status events into Tasks, or divert relevant follow-ups away from active work. We prefer to observe a proposed decision before allowing it to control effects.

In a shadow phase, a new classifier or policy evaluates Signals and records what it would have chosen while the established policy remains authoritative. The comparison can focus on decision classes and high-value slices:

ComparisonQuestion
New Task vs mailboxWould the candidate create duplicate ownership or hide a separate request?
Daemon vs normal TaskDoes the specialist’s activation scope match the event and repository?
Work vs no-opIs the candidate waking on automated noise or dropping explicit requests?
Same route, different repoDid repository inference change the actual execution authority?
Same route, new targetIs the candidate attaching input to the correct active owner?

Rollout can then proceed by provider, customer cohort, event type, repository class, or confidence band. The exact rollout controls are implementation details; the principle is stable. Ownership policy deserves staged evidence because mistakes create durable work.

Shadow results are not ground truth. Human labels can be incomplete, later context can change what the right decision looks like, and a classifier can agree with existing policy while both are wrong. Useful evaluation combines sampled review, downstream outcomes, duplicate-owner signals, mailbox correction rates, and explicit operator feedback.

Routing keeps one objective legible

Return to the three comments. The explicit request creates a Task after repository and agent resolution. The clarification becomes a mailbox append because it changes the same active objective. The automated status becomes a no-op because it does not independently ask for work. If repository policy matches one or more daemon roles, each selected role can wake or rejoin its bounded coordination owner.

Each route makes a different promise about responsibility. The scheduler’s effect keys protect repeat attempts at their boundaries. Active-state checks and terminal reconciliation prevent an append race from casually creating a second owner. Coordination keys let distinct daemon activations share one active role. None of those mechanisms can replace semantic policy, and none makes the whole system exactly once.

Routing turns a Signal into accountable work, a relevant continuation, scoped specialist activations, or an explainable decision to do nothing. That clarity lets every later phase know which Task or role owns the next action.

Continue the series

Previous: How We Built Charlie, Part 3: Turning Webhooks into Work. Next in the series: How We Built Charlie, Part 5: Scheduling Durable Work (forthcoming). Browse the full How We Built Charlie series.