Design: Use-Case Abstraction and Shadow Mode¶
Status: proposed, no code changes yet. Owner: controller team. Related: roadmap item 24.
1. Context and motivation¶
Osmia currently has one fully-built dispatch pipeline, ProcessTicket, and one
purpose-built parallel pipeline, ProcessIncidentEvent. The package comment at
the top of internal/controller/incident.go:3-11 states the relationship
plainly:
ProcessIncidentEventis a reconciler entry point that runs in parallel toProcessTicketrather than sharing code with it. ... A future refactor will lift bothProcessTicketandProcessIncidentEventbehind a common interface; until then, the duplication is bounded and intentional.
That comment was written with intent: incident triage was shipped as a second, narrower pipeline rather than as a generalisation of the first, because generalising a state machine from a single example tends to produce the wrong abstraction. Now that a second consumer shape exists (incident triage: no repository, no merge request, single engine, no approval gates) and roadmap item 24 describes a third (read-only analysis/reporting tasks with no git clone at all and a comment-and-notify result), the codebase has enough evidence to design the abstraction properly instead of guessing at it.
Roadmap item 24 ("Non-Standard Task Types (Analysis, Reporting, Review)",
docs/roadmap.md:230-256) already lists the open design questions this
document answers: execution-mode taxonomy, result-handler taxonomy, profile
dispatch, and prompt design. This document is the design doc that item asks
for. It does not implement the abstraction; it specifies what "use case"
means as a concept in Osmia, what the do-not-break contract is for the two
existing consumers, and how a third consumer would be added without another
bespoke ProcessXEvent function.
Future consumers this abstraction should accommodate, based on the
roadmap-24 sketch and the incident-triage precedent: read-only analysis
tasks (api_read or read_only execution mode, comment_and_notify result
handling), and any future webhook-driven flow that, like incident triage,
has no ticketing backend of its own.
2. Do-not-break contract¶
Any refactor must preserve every one of the following behaviours exactly. Each is cited with its current file and line so a reviewer can re-check it against the diff.
| Behaviour | Evidence |
|---|---|
Ticketing idempotency key is "<ticket.ID>-1" |
internal/controller/controller.go:508 |
Incident idempotency key is "<incident.ID>:<event.Type>" |
internal/controller/incident.go:79 |
Ticketing TaskRun ID format tr-<ticketID>-<unixMilli> |
internal/controller/controller.go:614 |
Incident TaskRun ID format tr-incident-<lower(incidentID)>-<eventSuffix>-<unixMilli> |
internal/controller/incident.go:119-123 |
Empty RepoURL produces a workspace-only prompt with no git instructions |
pkg/engine/claudecode/engine.go:563-566 |
Non-empty RepoURL produces the full clone/branch/push/MR prompt, further branching on SessionID, PriorBranchName, PriorMergeRequestURL |
pkg/engine/claudecode/engine.go:423-562 |
startStreamReader only runs when engineName == "claude-code" |
internal/controller/controller.go:773 (ticketing), internal/controller/incident.go:230-232 (incident, via defaultIncidentEngine constant) |
Pre-start approval gate (hasApprovalGate("pre_start")) exists for ticketing, is skipped entirely for incident triage |
internal/controller/controller.go:633-666; no equivalent call in incident.go |
Pre-merge approval gate (hasApprovalGate("pre_merge")) exists for ticketing's handleJobComplete, applies today to incident runs too because they share handleJobComplete |
internal/controller/controller.go:908-945 |
Episodic memory query and runNotifyStart run for ticketing only |
internal/controller/controller.go:670-687 (memory), controller.go:708 (runNotifyStart); incident.go:64-70 documents both as skipped |
ticketing.MarkInProgress runs for ticketing only |
internal/controller/controller.go:765 |
Per-flow Slack config: IncidentTriage.SlackChannelID / SlackTokenSecret, falling back to the first Notifications.Channels entry when empty |
internal/controller/incident.go:147-171, config fields at internal/config/config.go:199-216 |
Per-flow incident.io MCP credentials: IncidentTriage.IncidentIOAPIKeySecret sets SecretKeyRefs["INCIDENT_IO_API_KEY"], which setup-claude.sh reads to register the MCP server |
internal/controller/incident.go:172-185; internal/config/config.go:218-224; docker/engine-claude-code/setup-claude.sh:54-55 |
Webhook route /webhooks/incident-io, Svix signature verification |
internal/webhook/server.go:177; internal/webhook/incident.go:202 (verifySvixSignature); IncidentIOWebhookConfig at internal/config/config.go:174-181 |
Known incident-UUID wart: handleJobComplete calls ticketing.MarkComplete(ctx, tr.TicketID, result) with the incident ID as TicketID; the ticketing backend does not recognise it, logs a non-fatal error |
internal/controller/controller.go:1044-1050; documented at internal/controller/incident.go:72-77 |
Same wart on the failure path: handleJobFailed calls ticketing.MarkFailed(ctx, tr.TicketID, reason) |
internal/controller/controller.go:1533-1539 |
The last two rows are warts, not features, but "do not break" still applies in the narrow sense that today they degrade gracefully (a logged error, not a crash). Section 6 proposes fixing them as part of this refactor rather than preserving them, because a use-case-aware result handler makes the fix nearly free and the current behaviour produces noisy, confusing logs on every single incident run.
3. Decision: descriptor with hooks, not an interface, not pure config¶
Three shapes were considered.
Option A: fat UseCase interface. Each use case implements a Go interface
with a Process(ctx, event) error method that owns its entire pipeline,
mirroring how ProcessTicket and ProcessIncidentEvent work today. This is
the least disruptive option but does not solve the actual problem: it
formalises "one bespoke function per use case" instead of removing the
duplication. A third consumer under this option is still a full rewrite of
job launch, idempotency, gate checks, and completion handling.
Option B: pure YAML/config-driven use cases. Execution mode, result
handler, gates, and prompt shape are all declared in osmia-config.yaml with
no per-use-case Go code. This was rejected because several of the
distinguishing behaviours are not declarative: how the idempotency key is
built, how the TaskRun ID is formatted (DNS-1123 constraints differ per
source system), which fields populate engine.Task, and how Slack fallback
resolves are all pieces of logic, not scalar config values. Forcing this
logic into a config DSL would recreate a worse version of Go inside YAML.
Option C (chosen): a data-driven Definition descriptor with hook
functions, plus a ResultHandler interface, in a new internal/usecase
package. A use case is a struct of small, mostly-pure functions (build the
idempotency key, build the TaskRun ID, build the engine.Task, decide which
gates apply) registered by name. The shared pipeline in the controller loops
over data (which gates apply, which execution mode, which result handler)
rather than branching on if engineName == "claude-code"-style ad hoc
checks scattered through two files. New use cases are additions to a
registry map, not new top-level ProcessX functions.
The test this design must pass: adding a third consumer (the roadmap-24
read-only analysis flow) should require one new Definition value and one
new config block, not a new file that duplicates ProcessTicket's
plumbing. Section 4 defines the Definition shape that makes this true.
4. Use-case model¶
package usecase
// Definition describes one dispatch pipeline shape. It is a set of hook
// functions and declarative flags, not a Process() entry point: the
// shared controller pipeline calls these hooks in a fixed order, so a
// new use case cannot silently skip a step the way a hand-written
// ProcessX function could.
type Definition struct {
Name string
ExecutionMode ExecutionMode // see section 5
Gates Gates
Results ResultHandler // see section 6
Shadow ShadowConfig // see section 7
IdempotencyKey func(event any) string
TaskRunID func(event any) string
BuildTask func(event any) (engine.Task, error)
ConfigureEngine func(cfg *engine.EngineConfig, event any)
}
// Gates is a boolean set. Every field defaults to false so a new use
// case is gate-free (matching incident triage's current behaviour)
// unless it opts in explicitly.
type Gates struct {
PreStart bool // hold in NeedsHuman before launch
PreMerge bool // hold in NeedsHuman before marking complete
CodeReview bool // run the configured review backend
EpisodicMemory bool // query memory before building the prompt
NotifyStart bool // runNotifyStart / thread-ref injection
MarkInProgress bool // ticketing.MarkInProgress call
}
Gate table for the two existing consumers, derived from section 2's citations:
| Gate | Ticketing | Incident triage |
|---|---|---|
PreStart |
true (controller.go:633) |
false |
PreMerge |
true (controller.go:908, currently applies to incident runs too as a side effect of sharing handleJobComplete, see section 6) |
false today, becomes explicitly false under the refactor |
CodeReview |
true if config.CodeReview.Enabled (controller.go:950) |
false |
EpisodicMemory |
true (controller.go:670-687) |
false |
NotifyStart |
true (controller.go:708) |
false |
MarkInProgress |
true (controller.go:765) |
false |
The registry is a map[string]*Definition keyed by name (for example
"ticketing", "incident_triage"). A new field, TaskRun.UseCase string,
is persisted alongside the existing TaskRun fields
(internal/taskrun/taskrun.go:56- onward has no such field today) so that
handleJobComplete and handleJobFailed can look up the right
Definition for an in-flight or resumed TaskRun without re-deriving it from
the TaskRun ID's shape.
Until that field exists on every persisted TaskRun, the tr-incident-
prefix on the TaskRun ID (internal/controller/incident.go:119) is the only
signal available for inferring use case on old records. The refactor should
add a small legacy-inference shim, "TaskRun IDs starting with tr-incident-
without a persisted UseCase field are the incident_triage use case,
everything else is ticketing", and that shim should be removed at a release
boundary once no unresolved TaskRuns from before the migration remain (see
section 8).
Scope note: this design covers the launch tail and completion/failure
dispatch. ProcessTicket's front half, repo-URL resolution, Slack polling,
engine selection, cost estimation, and tournament dispatch
(controller.go:530-610), is out of scope for v1 and stays as-is; incident
triage does not use any of it today, and folding it into the shared
descriptor is a separate, larger piece of work that is not required to
satisfy roadmap item 24.
5. Execution-mode taxonomy¶
Roadmap item 24 already names the three modes needed:
clone_push_mr: today's default. Clone, branch, commit, push, open an MR.read_only: no git clone; the agent works against a live checkout or read-only mirror, or does not touch a repository filesystem at all.api_read: no workspace, no clone; the agent only calls SCM/ticketing APIs (for example, "list open MRs needing review").
This should become an explicit field, ExecutionMode on Definition and
(where a task can vary per-instance rather than per-use-case) an
engine.Task.ExecutionMode field, rather than being inferred solely from
RepoURL presence as it is today.
The existing inference must be preserved exactly, because it is what every
engine's BuildPrompt currently branches on:
RepoURL == ""produces the plain "work in /workspace, write result.json" prompt with no git instructions (pkg/engine/claudecode/engine.go:563-566).RepoURL != ""produces the full clone/branch/push/MR flow, further branching onSessionID(resumed session skips clone,engine.go:498-515),PriorBranchName(recovery clone of a prior branch,engine.go:432-453), andPriorMergeRequestURL(push to existing MR instead of opening a new one,engine.go:485-494).
The recommended approach is an effectiveMode() helper: if
Definition.ExecutionMode is unset, fall back to the empty-RepoURL
convention indefinitely, so this is additive rather than a breaking change
to every engine's BuildPrompt. Concretely:
func effectiveMode(task engine.Task, def *Definition) ExecutionMode {
if def.ExecutionMode != "" {
return def.ExecutionMode
}
if task.RepoURL == "" {
return ModeAPIRead // or ModeReadOnly, see open questions
}
return ModeClonePushMR
}
Each engine's BuildPrompt and BuildExecutionSpec would eventually branch
on effectiveMode() instead of task.RepoURL != "" directly, but the
inferred default for existing callers does not change, so this can land
without touching every engine implementation in the same PR. This taxonomy
should be coordinated with any parallel engine-parity work on the prompt
contract (for example Codex/Aider BuildPrompt equivalents), since
read_only and api_read prompts need equivalent treatment in every
engine, not only claude-code.
6. Result-handler taxonomy¶
Three handlers, one already implicit, one fixing existing warts, one new:
open_mr(today's implicit ticketing behaviour): on success, callticketing.MarkComplete; on failure, callticketing.MarkFailed; both keyed ontr.TicketID, which is a real ticket ID for this use case.notify_only: on success or failure, skipticketing.MarkComplete/MarkFailedentirely and only run the notification path (r.notifiers,updateNotificationStatus). This directly fixes both warts in section 2: incident triage'sTicketIDis an incident UUID that no ticketing backend recognises, and today's code callsMarkComplete/MarkFailedon it anyway (controller.go:1044-1050,controller.go:1533-1539), producing a logged-but-ignored error on every single incident run. Undernotify_only, incident triage's result handler simply does not call either ticketing method, so the error disappears rather than being suppressed after the fact.comment_and_notify(new, per roadmap item 24): on success, post the agent's summary as a ticket comment (distinct fromMarkComplete, which transitions ticket state) and notify configured channels; no MR is expected. This is the handler a read-only analysis use case would use.
Dispatch becomes a lookup on Definition.Results (an interface with
OnSuccess(ctx, tr, result) error and OnFailure(ctx, tr, reason) error)
inside handleJobComplete and handleJobFailed, replacing the current
unconditional r.ticketing.MarkComplete / MarkFailed calls at
controller.go:1044-1050 and controller.go:1533-1539.
Behaviour tightening to flag explicitly: today, because
handleJobComplete is shared code with no use-case awareness, incident
runs pass through the exact same pre-merge approval gate check as ticketing
runs (controller.go:908, hasApprovalGate("pre_merge")). In practice this
gate is never held for incident runs today only because operators do not
configure a pre_merge gate on deployments that also run incident triage,
not because the code prevents it. Once Gates.PreMerge is explicit per
Definition (section 4) and incident triage's Definition sets it to
false, an incident run can no longer be accidentally held at a pre-merge
approval gate, even if an operator later enables that gate for the
ticketing flow. This is a deliberate behaviour change, not an oversight: it
formalises what is currently true only by convention, but it means an
operator who was relying on the shared code path to gate incident runs
(unlikely, since no config exposes that combination as intentional) would
need to add an explicit incident-triage gate config once one exists.
7. Shadow mode¶
Shadow mode lets a use case run end to end, including calling out to external systems for read purposes, while suppressing any write/mutating side effect (ticket state changes, MR creation, Slack "task complete" posts) so that a new use case or a risky prompt change can be validated in production traffic without user-visible consequences.
Config surface. A global shadow block sets the default; each use
case's config block, for example incident_triage.shadow, can override it;
a task-profile-level flag can override both for a specific task type.
Default is off everywhere. This mirrors the existing per-flow override
pattern already used for IncidentTriage.Engine and
IncidentTriage.AppendSystemPrompt (internal/config/config.go:188-225).
Four enforcement layers, from strongest to weakest:
- Controller-enforced result-handler suppression (hard). When
TaskRun.Shadowis true, theDefinition.Resultsdispatch in section 6 is bypassed entirely in favour of a shadow-only handler that logs the would-be action and posts to a dedicated shadow-feedback channel instead of callingticketing.MarkComplete/MarkFailed, posting a real Slack completion message, or registering an MR with the review poller. This layer also suppressesrecordTaskOutcomeandextractMemory(controller.go:1073,controller.go:1093), because shadow runs should not influence engine calibration or episodic memory built from real production outcomes. - Prompt preamble plus
ProposedActions(soft). The prompt gains a preamble telling the agent it is running in shadow mode: it should still do its normal analysis and decide what it would do, but describe the action rather than performing it where practical (for example, describe the MR it would open rather than opening one).TaskResultgains aProposedActions []stringfield for the agent to report this explicitly. This layer is soft because it depends on the agent following the instruction; nothing in the harness verifies compliance. - In-pod PreToolUse guard hook (nominally hard, honestly mostly
inert). The plan for this layer is a
PreToolUsehook that blocks mutating tool calls (git push, SCM write calls) whenOSMIA_SHADOW=1is set in the pod environment, merged intoGenerateHooksConfig's existingBash/Write|Editmatcher blocks (pkg/engine/claudecode/hooks.go:71-133). Three things must be stated honestly about this layer, because they materially weaken it: GenerateHooksConfighas no production caller today; the only caller in the repository is its own test,pkg/engine/claudecode/engine_test.go:1243. The generated hooks JSON is never written into a running pod's settings.docker/engine-claude-code/settings.jsonships only apermissionsblock; it has nohookskey at all today.- The scripts a hooks config would reference
(
/opt/osmia/hooks/heartbeat.sh,/opt/osmia/hooks/on-complete.sh) do not exist on disk; onlyblock-dangerous-commands.shandblock-sensitive-files.shexist, indocker/engine-claude-code/hooks/. - Even once wired, a
Bash/Write|Editmatcher hook cannot intercept MCP tool calls (for example a hypothetical ticket-comment MCP tool), because MCP tool invocations are a different tool-call shape and do not match those matchers. So this layer, even at full strength, only covers shell/file-write mutation, not MCP-mediated mutation.
Given this, layer 3 should be described in any shadow-mode implementation plan as a gap to close, not a control that already exists. Layer 1 (controller-enforced suppression) is the layer that actually protects production state today and should be treated as the real guarantee; layers 2 and 3 are best-effort/defence-in-depth once built. 4. Credential minimisation (documented, not enforced). Operators running shadow-mode use cases are advised to configure a read-only or scoped-down SCM/ticketing token for the shadow deployment, since the harness itself does not currently enforce least-privilege on a per-shadow-run basis. This is a deployment-time recommendation, not a code-level control.
Annotations and observability. A TaskRun.Shadow bool field, a Job
label osmia.io/shadow: "true", and a new Prometheus counter
osmia_shadow_task_runs_total (labelled by use case and outcome) give
operators a way to find and count shadow runs distinctly from real ones.
Feedback loop, v1 scope. For the first version, feedback from shadow runs is a structured post to a dedicated shadow-feedback channel (what the agent proposed, whether it succeeded, cost, duration), for a human to read and compare against expectations. Automated comparison against a golden set, or promotion of a shadow use case to live status, is out of scope for v1 and is not designed here.
8. Migration and sequencing¶
The downstream ArgoCD deployment tracks main continuously (see project
memory: the 0.0.0-edge Helm chart is republished on every push to main
and pulled automatically). This means every merged PR in this refactor must
leave the system in a fully working, individually deployable state; there
is no batched "big bang" release to hide behind.
Recommended sequencing:
- Contract tests first. Write tests that pin every row in section 2's
do-not-break table (idempotency key formats, TaskRun ID formats, prompt
branching on
RepoURL/SessionID/PriorBranchName, gate applicability per flow, the twoMarkComplete/MarkFailedcall sites) against today's code, before any refactor lands. These tests are the safety net for every subsequent step. - Strangler refactor, one behaviour at a time, each landing as its own
deployable PR: introduce the
internal/usecasepackage andDefinitiontype without changing any call site; wireProcessIncidentEventto build itsDefinitionand route through the new gate/result-handler dispatch (this is the PR that fixes theMarkComplete/MarkFailedwarts, since incident triage is the simpler of the two flows to convert first); leaveProcessTicket's front half untouched per section 4's scope note and only route its completion/failure tail through the same dispatch thatProcessIncidentEventnow uses. - Release boundary before removing the
tr-incident-legacy shim (section 4). The shim can only be safely deleted once every in-flight/resumable TaskRun created before theTaskRun.UseCasefield existed has reached a terminal state, which in practice means picking a release/version boundary and documenting it (analogous to theNoSessionPersistencefield removal precedent already in this codebase) rather than deleting it opportunistically.
9. Open questions¶
- Mutating incident.io MCP tool deny-list naming. Once incident triage gains any mutating incident.io MCP tools (for example, updating incident status), shadow mode's layer 3 (section 7) needs a name-based deny-list for those specific MCP tool names, since matcher-based hooks cannot see them. What that deny-list configuration surface looks like (per-tool allow-list versus deny-list, where it is declared) is not decided here.
- Read-only token strategy for clone-mode shadow. If a shadow-mode use
case still uses
clone_push_mrexecution mode (to validate a prompt change end to end before trusting it with real MRs), what credential should it clone with so that an agent cannot push even if layer 1 or 2 fails? A read-only deploy token scoped per shadow run is one option; the operational cost of provisioning those tokens is not assessed here. - Whether to fix the hooks-wiring gap as part of this work. Section
7's honest note is that
GenerateHooksConfigis currently dead code with no caller, no correspondinghookskey insettings.json, and missing target scripts. Should wiring it up (calling it from job/pod setup, writingheartbeat.sh/on-complete.sh, adding thehookskey tosettings.json) be done as a prerequisite PR in this same sequencing, or tracked as a separate, independent piece of work? This document takes no position; it only records that shadow mode's layer 3 depends on that gap being closed to be more than aspirational.