overview / agentic-workflows / 02-solution-strategy

Solution Strategy Workflow

Last updated at 2026-07-30 AEST.

A high-level walkthrough of how the platform turns a user's "Generate solution strategy" click into a stored, versioned, self-evaluated strategy that downstream features (solution generation, the solution builder UI) can build on.

At a glance

A handful of numbers capture the shape of the workflow:

3 (+1)
progress steps Β· +1 when requirements drifted
1 + ≀ 2
strategy agent passes (initial + revise budget)
11
deterministic scorer rules
4
subjective rules the judge may raise
80
default quality-bar score, out of 100
5
service classes checked for sellability
0
files written β€” output is pure data

Overview

The solution strategy is the upstream design decision: it captures which service categories, vendors, product families, delivery motions and service wrappers the platform recommends for this deal, structured as JSON. A user reviews, overrides, and approves a strategy version; solution generation then grounds on the approved, non-stale one. The characteristics below are grounded in the mechanisms documented in the rest of this file β€” they describe what the code actually does, not aspirations.

Self-evaluating

Every run scores its own output: eleven deterministic scorers mirror the generation prompt's hard rules, a set of deterministic correctors repairs the mechanically-fixable violations in place, and a frontier judge agent adds the four subjective qualities code can't check.

Self-refining, bounded

When a round misses the quality bar and at least one of its HIGH findings is routable, the loop regenerates the whole strategy with the findings as corrective guidance and re-scores β€” capped by a revise budget and stopped early on a plateau or an unfixable finding. The best round wins.

Requirements-fresh by construction

If the deal's latest context no longer matches its current approved requirement set, the run's first phase re-synthesizes that context inline β€” a nested run of the full deal-context workflow β€” so the strategy is never built on stale requirements.

Runs off-request, in its own process

The mutation is fire-and-forget: a small validated kickoff that hands off to a Pub/Sub topic. In every deployed environment the work runs in a dedicated Cloud Run Job execution, sized for the nested deal-context refresh it may perform.

Idempotent and resumable

Each visible step caches its result and short-circuits on WorkflowStep.completedAtUtc; on retry only the unfinished steps re-run. The refinement loop's round history lives in Mastra-snapshotted state and is simply recomputed on a redelivery.

Additive-versioned

The job is claimed atomically so two workers can't double-process, and SolutionStrategy rows are a new version per run β€” nothing overwritten β€” so a partial retry can never corrupt an earlier version. Version races resolve by a unique-constraint retry, not an advisory lock.

Robust failure handling

Failures are classified deterministic versus transient; the pull and Cloud Run Job paths route them oppositely (see Failure handling). Cancellation exits cleanly, including mid-refresh.

Lightweight kickoff

Unlike proposal generation, the mutation creates no placeholder output row, takes no advisory lock, and does not advance the deal stage β€” those are deferred to the runner's success path. The frontend tracks live progress from the AsyncProcessingJob row alone.

Traceability and auditability

Every run pins an immutable SolutionContextSnapshot of its exact inputs, writes a Generation audit row for the main agent call, and links each version to the snapshot, the deal context (and its captured version), the generation, an approved-requirements fingerprint, and its own evaluation.

Consultant-editable, non-destructively

Path overrides, commercial selections, and prose edits layer over the frozen AI value, which is never mutated. Editing auto-unapproves; acknowledging a finding does not.

Multi-tenant and real-time

All data is scoped to the caller's organization, and the terminal state transition is published over Redis pub/sub (solutionStrategyChanged) so the new version appears live across every open client tab without a manual refresh.

Security and grounding

Identity is taken from the auth context, never from the caller. The agent may only reference categories, vendors, product families, and service classes present in the compiled context β€” and where the prompt alone isn't enough, the correctors and scorers enforce it deterministically after the fact.

The remainder of this document walks the flow end to end, from the triggering mutation through to where the finished strategy is stored.

1. Entry Point

The user triggers strategy generation from the web app, which calls a single GraphQL mutation on the API:

  • Mutation: initiateSolutionStrategyGeneration

This is a "fire-and-forget" mutation: it returns almost immediately with a job handle. The actual generation runs asynchronously in the background, and the frontend tracks progress through GraphQL subscriptions.

2. Input Required

The mutation accepts the smallest possible InitiateSolutionStrategyGenerationInput:

FieldRequiredPurpose
dealIdYesThe deal the strategy is being generated for. Everything else (organization, user, requirements, context) is resolved from this.

No knobs by design. There are no custom instructions or force-regeneration flags at this layer β€” every run produces a fresh strategy version, and the refinement loop's behaviour is environment configuration, not a per-request option.

The logged-in user is taken from the auth context β€” the resolver never trusts a caller-supplied user or organization ID.

Preconditions

Before the resolver enqueues anything, it validates that the deal is in a state that can actually produce a strategy:

  1. The deal exists and belongs to the caller's organization.
  2. The deal has at least one approved deal requirement. The strategy is built against the approved set only β€” drafts and rejected requirements are excluded.
  3. The deal has at least one deal context.

If any check fails, the mutation throws a BadRequestException with a human-readable reason β€” nothing is queued. The approved-requirements check is re-run inside the runner, because it could have raced against a requirement being un-approved between the mutation and message dispatch.

The "kickoff" work

Compared to proposal generation, the kickoff is deliberately lightweight:

  1. Creates an AsyncProcessingJob of type GenerateSolutionStrategy, linked to the deal.
  2. Publishes the Pub/Sub message to the strategy generation topic.

There is no placeholder output row, no advisory lock, and no immediate deal-stage advance at kickoff β€” those are intentionally deferred to the success path inside the runner. The frontend tracks live progress via the AsyncProcessingJob row alone.

3. The Pub/Sub Message

The resolver publishes a message to a Google Cloud Pub/Sub topic. This is the hand-off from the synchronous request to the background execution path.

  • Topic: <environment>.deal-output.generate-solution-strategy (the literal name is environment-prefixed and resolved per environment via env vars)
  • Schema: solutionStrategyGenerationPubSubMessageDataSchema

Payload:

{
  "correlationId": "<asyncProcessingJobId>",
  "payload": {
    "asyncProcessingJobId": "...",
    "dealId": "...",
    "userId": "..."              // who triggered the run
  }
}

The message is tiny β€” just three pointers. Everything else is resolved from the database. No business data is carried on the wire.

4. How the Message Is Executed

As with every long-running workflow on the platform, there are two execution paths, selected per workflow by the WORKFLOW_PUSH_JOBS_ENABLED env var. Solution strategy is in that set in dev and both production regions:

  • Push path (deployed). A push subscription POSTs the message to the OIDC-gated workflow-dispatcher Cloud Run service, which claims the job with a compare-and-swap (Pending β†’ InProgress) and then starts a dedicated Cloud Run Job execution, stamping the workflow type and the verbatim base64 message data as container-override env vars. The Job's entrypoint boots a slim Nest application context (runner services only β€” no HTTP, no GraphQL, no subscribers), re-validates the payload through the same Zod schema, publishes the InProgress event (the dispatcher has no Redis), and drives the shared runner.
  • Pull path (local). An in-process streaming-pull listener inside the API claims the job and drives the same shared runner.

Why this Job is sized like the deal-context Job (4Gi container, a --max-old-space-size=2560 V8 heap): it runs the deal-context generation workflow in-process as its leading refresh phase, so it inherits that workflow's retained-set profile. Skipping asset analysis bounds the refresh's wall-clock, not its heap β€” the synthesis agent's stream buffers and retained result are the same either way. Both Job descriptors are sized together for exactly this reason.

What the shared runner does

The workflow body lives in SolutionStrategyGenerationRunnerService:

  1. Re-read the job pointers β€” the deal id and organization id from the DealAsyncProcessingJob row.
  2. Re-validate that approved deal requirements still exist.
  3. Re-check the grounding. Every delivery β€” a manual retry as much as an automatic redelivery β€” re-asserts that this run can still produce a correct strategy. It is refused in two cases: it carries completed work but the deal's latest context is no longer the one it was compiled against, or its context now reads stale against the approved requirement set and its persisted step set holds no unfinished "Refreshing requirements" step to fix that. The second case is the reason the drift recompute above cannot live only on the first delivery: a run whose step set was fixed before the requirements moved has no way to self-heal, so resuming it would generate against a context nobody approved. Both refusals are deterministic, so the message is acknowledged and the job goes terminal carrying the reason, rather than redelivering against a condition that cannot clear by itself. A run carrying nothing is never refused β€” it recompiles its context from scratch anyway.
  4. Decide the step set. On the first delivery, compute whether the deal's latest context has drifted from its current approved requirement set (see below). If it has, prepend a "Refreshing requirements" step to the workflow's own steps. A redelivery reuses the persisted WorkflowProgress verbatim, so the step set is decided once and never rebuilt.
  5. Initialize or restore WorkflowProgress plus one WorkflowStep row per step. On retry, only the not-yet-completed steps are reset.
  6. Run the inline deal-context refresh if the refresh step exists and hasn't already completed (section 5).
  7. Compile the solution context into a SolutionContextSnapshot β€” an immutable, point-in-time aggregation of everything the agent will read β€” then record the deal context that compile resolved onto WorkflowProgress, which is what a later delivery compares its carried-over work against (section 6).
  8. Start the Mastra workflow run (section 7), surfacing the run handle synchronously so a cancel arriving before the first step can still abort it.
  9. On completion: publish solutionStrategyChanged, append a SolutionStrategyGenerated deal-history activity (idempotent on the persisted strategy id), mark the job Completed, and advance the deal stage to Solutioning if it isn't already past it.

Failure handling

Failures are classified deterministic (a schema/contract violation surfaced as DeterministicWorkflowError, which reproduces on replay) versus transient (network, provider blip, DB disconnect, OOM). The two execution paths route them oppositely, because "retry" means something different in each:

Failure-handling decision tree workflow run did not succeed deterministic transient cancelled mark Failed, stop pull: ack the message push: exit 0 (nothing to retry) retry, from checkpoints pull: revert to Pending + nack push: stay InProgress + exit 1 clean exit no failure flip retry budget exhausted terminal failure pull: dead-letter topic push: 3 task retries, then swept an uncatchable death (OOM / SIGKILL / boot crash) is terminalized out-of-band by a Cloud Logging sink
Same classification, opposite routing: the pull path hands the retry back to Pub/Sub, the Job path keeps the row claimed and lets Cloud Run task-retry resume it.
  • Deterministic failures mark the job Failed and stop β€” acked on pull, exit 0 on push.
  • Transient failures retry. On pull the job reverts to Pending and the message nacks (Pub/Sub redelivers up to the retry budget, then dead-letters). On push the row stays InProgress and the process exits non-zero so Cloud Run task-retry (3 retries, 1-hour per-task timeout) resumes from the WorkflowStep checkpoints.
  • A failed inline refresh throws, inheriting whichever classification the nested deal-context run produced β€” so the strategy is never generated against a context the refresh failed to update.
  • Cancellation is checked at multiple points, including mid-refresh; a cancelled job exits cleanly without flipping to failed.
  • A recovery sweep runs on startup and every 15 minutes, failing jobs stuck past their stale thresholds. On the push path the stale-InProgress threshold widens to 270 minutes so it can't reap a Job mid task-retry.

5. Keeping the Requirements Fresh (the inline refresh)

A strategy built on a deal context that predates the current approved requirement set is wrong before it starts. Because the solution context compiler snapshots only the latest DealContext, the fix has to happen before compilation β€” so the runner performs a staleness-gated inline refresh as the job's leading phase.

Staleness-gated inline deal-context refresh Is the latest deal context stale? recorded approvedRequirementsSnapshot vs the current approved fingerprint stale Β· never snapshotted Β· absent matches "Refreshing requirements" nested dealContextGenerationWorkflow run requirement-change shape β€” no asset writes into THIS job's progress row no refresh step 3 progress steps instead of 4 compile SolutionContextSnapshot β†’ run the workflow
The refresh is a conditional leading phase, not a separate job. Purple = LLM agent work, blue = deterministic code.

The staleness test reuses the exact primitives the DealContext.isStale resolver uses, so the rule is never forked: a deal with no context, or a context with no recorded requirement baseline, cannot be proven current and therefore counts as stale; otherwise the recorded snapshot is diffed against the freshly-built fingerprint of the current approved set.

When it fires, the runner starts dealContextGenerationWorkflow as a nested run inside this same job, with the requirement-change shape (no assetId) so the asset-analysis step early-returns and only the synthesis agent runs. Three properties make this safe rather than surprising:

  • It surfaces as one step, not a second job. The nested run's request context points at this job's WorkflowProgress and at the refresh WorkflowStep row, so the live processing badge shows "Refreshing requirements" and the deal-context step's own isStepAlreadyCompleted guard short-circuits it on replay. The step message is deliberately requirement-framed β€” the refresh is an internal detail of the single "Generate solution strategy" action.
  • It is cancel-abortable. The nested run handle is surfaced through the same registry hook; the main strategy run's handle later overwrites it (both keyed on the stable job id), so a cancel always aborts whichever run is currently active.
  • It publishes normally. The nested run writes a real new DealContext version and publishes dealContextChanged, so every open tab sees it.

This path deliberately does not take the per-deal deal-context job lock β€” it runs under the already-claimed strategy job. A concurrent asset-analysis GenerateDealContext job could therefore create a version alongside it; that is tolerable because DealContext is additive-versioned and the latest version wins at compile time.

6. Data Retrieved for the Run

Before the workflow runs, the runner assembles everything the AI agent will need. All of this is read once, up front, so the workflow itself issues no ad-hoc aggregation reads mid-run:

SourceWhat's read
DealDeal ID and organization ID.
DealRequirement (approved only)Confirms approved requirements exist; the snapshot below pulls in the full set.
SolutionContextSnapshotBuilt fresh on every run β€” see below.
DealContext (pinned via the snapshot)The latest captured context for the deal β€” refreshed first if it had drifted (section 5).
InstitutionalOverlayVersion (published)The organization's institutional profile (active categories, vendor candidates with preference/exclusion status, product families, service offerings).
CommercialProfileVersion (published)The organization's commercial profile (rate cards, pricing). Used to figure out which service classes are commercially viable.
OntologyRelease (active)The shared canonical ontology that maps requirements to vendor/product families.

The solution context snapshot is the heaviest piece. It's compiled by ContextCompilerService.buildSolutionContext and pulls together:

  • The deal context (commercial constraints, current state, customer profile, NFRs, objectives, sizing, technology profile).
  • The grounded approved requirements (mapped against the active ontology).
  • The active institutional overlay β€” which category, vendor, product-family and service-offering candidates are valid for this organization, and which are preferred or excluded.
  • The commercial availability picture: which of the five service classes (Advisory, Implementation, ManagedService, Migration, Support) are actually sellable based on the rate card.
  • Completeness indicators that downstream quality gates and UI badges can read off.
Compiling the solution context snapshot DealContext latest β€” refreshed if stale Approved requirements grounded via the ontology Institutional overlay published version Commercial profile rate cards Β· pricing buildSolutionContext ContextCompilerService β€” read once, up front commercial availability Β· 5 service classes + completeness indicators SolutionContextSnapshot immutable Β· persisted to Postgres for replay links overlay, profile + ontology versions
Four version-pinned sources fan into the compiler (blue = deterministic code), which emits one immutable snapshot (grey = persisted data).

The snapshot is persisted to Postgres so the exact inputs that produced a given strategy version can be replayed later if needed, and it doubles as the deterministic lookup table the correctors and scorers score against (section 8). Version races on the snapshot itself resolve by a unique-constraint retry.

7. How Data Flows Through the Workflow

The workflow is a Mastra workflow (solutionStrategyWorkflow) of three visible steps wrapped around a bounded self-refining loop:

generate β†’ evaluate β†’ dountil(revise-body, stop?) β†’ restore-best-round β†’ store

Only generate-solution-strategy, evaluate-solution-strategy and store-solution-strategy are registered as progress steps. The loop body's steps and the restore step use ids deliberately absent from that map, so they are invisible to the fixed step-count accounting and write no WorkflowStep or Generation rows β€” the loop's entire record lives in Mastra-snapshotted workflow state and is simply recomputed on a redelivery. To the UI, the whole loop is one opaque "Evaluating solution strategy" step: the evaluate step writes its started transition and the post-loop restore step writes its completed.

Step graph

Solution strategy step graph Refreshing requirements (conditional) nested deal-context run Β· section 5 generate-solution-strategy solutionStrategyGenerationAgent structured JSON Β· Zod-validated Β· Generation row one opaque UI step β€” "Evaluating solution strategy" evaluate-solution-strategy round 0: correct β†’ score β†’ judge deterministic-only when refinement is off dountil( begin β†’ regenerate β†’ re-evaluate ) invisible steps Β· no WorkflowStep / Generation rows Β· ≀ maxIterations passes next round restore-best-round β€” closes the opaque step store-solution-strategy new SolutionStrategy version + its evaluation version collision β†’ re-read max + retry Postgres Context snapshot immutable inputs Purple = LLM agent work Β· blue = deterministic code Β· green = terminal selection Β· grey = datastores
Three visible steps; the evaluate β†’ revise β†’ re-evaluate cycle hides inside one of them.

What each step does

  • Generate solution strategy β€” calls the solutionStrategyGenerationAgent with:

    • The system prompt baked into the agent (rules for how to pick categories, vendors, families, delivery motions, committed paths, alternatives, and the engagement mix), whose JSON output shape is derived from the Zod output schema so the schema stays the single source of truth.
    • A system message containing the full solution context snapshot payload (section 6).
    • A short user prompt: "Generate a partner-specific solution strategy based on the provided solution context."

    The main pass is a single agent.generate call with structuredOutput, validated against solutionStrategyGenerationAgentOutputSchema. The full response (model used, prompts, raw text, token usage, response time, success/failure) is recorded in a Generation row for traceability and cost accounting. If schema validation fails, the step throws a deterministic error.

  • Evaluate solution strategy β€” opens the opaque step and builds round 0: run the deterministic correctors, score the corrected value with the deterministic scorers, and (when refinement is enabled) run the judge agent and merge its subjective findings in. When refinement is disabled this is a purely advisory deterministic evaluation with no judge call, and the loop below is a no-op. An unparseable solution context skips evaluation entirely (a zero-finding advisory result) rather than failing the run.

  • The revise loop body (a committed sub-workflow, so it can serve as a Mastra dountil body) β€” begin decides whether this pass should actually revise and flips the revise-pass flag; regenerate re-invokes the generation agent with the round's routable HIGH findings as corrective guidance; re-evaluate scores the new value and appends the next round. The body always runs at least once (do-until semantics), so a pass that shouldn't revise simply no-ops.

    The revise regenerate does not reuse the main pass's agent.generate({ structuredOutput }) call. Provider enforcement of a nested output schema is unreliable at runtime, so the revise path uses the streaming route with jsonrepair and a bounded retry β€” the same shape the judge uses.

  • Restore best round β€” writes the winning round's value and evaluation into the flat state fields the store step reads, and closes the opaque step. A no-op when the loop never ran.

  • Store solution strategy β€” looks up the deal's current max SolutionStrategy.version, builds a fresh approved-requirements fingerprint, resolves the captured deal-context version, and persists a new row (fields in section 9). A version collision (P2002) is resolved by re-reading the max and retrying the create.

Caching, retries, and progress

  • Every visible step writes a WorkflowStep row with started, completed, or failed timestamps. This is what powers the live progress messages the frontend shows: "Refreshing requirements" (when it applies), "Generating solution strategy", "Evaluating solution strategy", "Storing solution strategy".
  • On retry, a step that already completed against the same job is short-circuited β€” the previously persisted strategy ID is read straight from WorkflowStep.result.
  • The refinement loop is deliberately not checkpointed. Its rounds live only in Mastra-snapshotted state, so a redelivery re-runs generate β†’ round 0 β†’ loop from scratch. That keeps the subscriber's fixed step-count accounting intact at the cost of re-spending the loop's agent calls on a redelivery.

8. The Evaluation Rubric & the Refinement Loop

The evaluation is deliberately mostly deterministic. The generation prompt states hard rules; the evaluator re-checks those same rules in code, so a violation is caught even when the model ignored the instruction. Only the residual qualities code genuinely cannot check are delegated to a judge agent.

Three layers

Deterministic repairs applied to the agent's value before it is scored or persisted β€” the mechanically-fixable subset, each recorded as a visible auto-correction note:

  • Drop a committed path whose vendor is invented (absent from every candidate set) β€” there is nothing valid to fall back to.
  • Re-point a committed vendor that is excluded for its category to the strongest selectable candidate; drop the path when none exists.
  • Drop product families that are inactive or excluded for the (possibly re-pointed) vendor.
  • Filter each path's nested alternatives, removing invented and excluded vendors.
  • Filter service-wrapper ids down to those present in the commercial availability picture.

Violations needing judgement (e.g. tower coverage) are deliberately not silently rewritten β€” they surface as findings instead.

How the score is computed

The overall score is deterministic and deduction-based, not model-assigned: start at 100 and subtract per finding, deterministic and judge findings alike.

high severity βˆ’15 βˆ’15
medium severity βˆ’7 βˆ’7
low severity βˆ’3 βˆ’3
Per-finding deductions from a starting score of 100, floored at 0.

Each evaluation is stamped with the rubric version that produced it β€” the deterministic version alone when no judge ran, or <deterministic>+<judge> when it did β€” so historical scores stay attributable as the rubric evolves.

The loop's stop conditions

Round 0 is the pre-loop evaluation; each subsequent round is one completed revise pass. The loop continues only when all of these hold:

  1. Refinement is enabled.
  2. The latest round misses the quality bar β€” the bar being zero HIGH findings and a score at or above the threshold (default 80).
  3. At least one of its remaining HIGH findings is routable.
  4. The revise budget (maxIterations, default 2) is not yet spent.
  5. The round has not plateaued β€” it improved either the HIGH count or the score versus the round before it.

Routability is the interesting one. A revise pass works by re-invoking the generation agent, so a finding is routable only if regeneration could plausibly fix it: the deterministic contract violations (the agent can re-select a valid path) plus rationale-soundness, current-state-framing and alternative-distinctness (it can re-write the prose or re-pick a genuinely distinct alternative). confidence-calibration is deliberately excluded β€” a regeneration that restates the same thin grounding cannot honestly raise the evidence its calibration is judged against. So a round whose only HIGH finding is a calibration miss stops the loop instead of burning a pass, and the unrouteable findings are logged for diagnosis rather than silently inflating cost.

Best round = fewest HIGH findings, then highest overall score, with ties resolving to the earliest round β€” a later round must strictly beat the incumbent to win, which spares already-spent agent calls.

Configuration

SettingDefaultEffect
…REFINEMENT_IS_ENABLEDtrueWhen off, the evaluate step produces a deterministic-only advisory evaluation and the loop is a no-op.
…REFINEMENT_MAX_ITERATIONS2Revise-pass budget.
…REFINEMENT_SCORE_THRESHOLD80The score half of the quality bar.
…REFINEMENT_IN_LOOP_JUDGE_RUN_COUNT1Judge runs merged per round.
…REFINEMENT_SEVERITY_GATEhighPlumbed end to end but not yet consumed β€” the loop gates exclusively on HIGH findings today.

The runner stages these onto the Mastra request context before the run starts, because Mastra steps can't read Nest config directly.

9. Where the Generated Strategy Is Stored

Unlike proposal generation, there is no file output and nothing is uploaded to Google Cloud Storage. The solution strategy is purely structured data: a row in the SolutionStrategy table whose value column holds the strategy JSON.

ColumnWhat it carries
valueThe frozen AI strategy JSON (the best round's value). Never mutated by consultant edits.
evaluationThe winning round's evaluation: findings, auto-corrections, overall score, rubric version, and the consultant's acknowledged finding keys.
generationIdThe main agent call that produced it (1:1).
solutionContextSnapshotIdThe exact compiled inputs β€” replayable.
dealContextIdThe deal-context version pinned by the snapshot.
capturedDealContextVersionThat version's number, so staleness can later detect deal-context drift (a strictly newer version exists).
approvedRequirementsSnapshotA fingerprint of the approved requirement set at generation time β€” the requirement-drift half of staleness.
userSelectionsConsultant overrides: excluded service offerings, delivery-motion toggles, selected path per category, prose edits. Layered over value on read.
isApproved / approvedAtUtc / approvedByUserIdThe approval state and who set it.
versionThe new sequential version, unique per deal.
SolutionStrategy version lineage SolutionStrategy vN value β€” frozen strategy JSON evaluation β€” its own scorecard Generation generationId prompts Β· raw output Β· usage Β· timing SolutionContextSnapshot solutionContextSnapshotId the exact inputs β€” replayable DealContext dealContextId + capturedDealContextVersion detects context drift Requirements fingerprint approvedRequirementsSnapshot detects requirement drift
Every version's full lineage: grey = separate persisted rows it links to; the fingerprint and the evaluation are values stored on the strategy row itself.

Once the workflow finishes successfully, the runner:

  1. Loads the newly-created SolutionStrategy by ID.
  2. Publishes a solutionStrategyChanged CREATED event through Redis pub/sub so every view subscribed to it (the deal page, the solution builder card, sibling tabs) evicts its cache and refetches before the next step.
  3. Appends a SolutionStrategyGenerated deal-history activity (idempotent, keyed on the persisted strategy ID, so a redelivery can't duplicate it) so the deal-history view has it on the wire before the job flips to Completed.
  4. Marks the AsyncProcessingJob as Completed so the frontend's "Generating…" UI state clears.
  5. Advances the deal stage to Solutioning if the deal is not already past it.

What the consultant can do with it

The frontend reads the SolutionStrategy row to render the strategy itself (categories, committed paths, alternatives, delivery motion, service wrappers, engagement mix) plus its evaluation panel, and exposes a set of mutations that all layer over the frozen value:

  • Override the committed vendor path for one service category (selecting the committed vendor again clears the override).
  • Update the commercial selection β€” delivery-motion toggles, excluded service offerings. Passing null clears an override and falls back to the recommended value.
  • Layer prose edits over the AI-generated narrative.
  • Acknowledge (or un-acknowledge) an individual finding by its deterministic key.
  • Approve / unapprove the version.

The semantics are deliberately asymmetric: editing the selection or the prose auto-unapproves the strategy, while acknowledging a finding keeps approval (it is a judgement about a known issue, not a change to the recommendation). Acknowledgements are stored inside the evaluation, so they auto-clear when the strategy is regenerated. Unapproving is blocked when the strategy is already used by a generated proposal or a proposal generation is in flight.

10. Where This Sits in the Chain

Approval of a strategy is not the proposal gate β€” it is the solution-generation gate:

  1. Solution generation (see Solution Generation) grounds on the deal's usable approved strategy: approved and non-stale. A stale approved strategy is not valid grounding, so it counts as no strategy at all, and the solution run is refused rather than generating on bad inputs β€” on the initial request and on every later delivery alike.
  2. Proposal generation (see Proposal Generation) gates on an approved, non-stale SolutionModel version β€” and derives the strategy from that model's link, rather than picking the approved strategy itself.

Staleness is evaluated on two axes across the whole chain, always with the same shared primitives: requirement drift (the recorded approvedRequirementsSnapshot versus the current approved fingerprint) and deal-context drift (a strictly newer DealContext.version than the captured one; a null captured version is deliberately not stale).

11. Security

The flow handles customer-supplied content and the organization's own institutional and commercial profile, so security applies at the API boundary, on the wire, at the dispatch hop, in storage, and inside the agent.

  • Identity is never caller-supplied. The triggering mutation derives the logged-in user β€” and therefore the organization β€” from the authenticated request context; it never trusts a user or organization ID sent by the caller. Every database read and write in the kickoff and the runner is scoped to that resolved organizationId.
  • Input is handled at the boundary. InitiateSolutionStrategyGenerationInput carries only a dealId, trimmed at the resolver via TrimPipe, and the resolver enforces the section-2 preconditions before anything is queued. (The dealId field itself has no class-validator length/format check; the preconditions' org-scoped findFirstOrThrow is what rejects a non-existent or cross-org id.)
  • The dispatch hop is authenticated and minimally privileged. Every dispatcher route is gated by GoogleOidcGuard against a fixed audience, and the dispatcher holds only the database-connection secret β€” never the API's full secret set.
  • The Pub/Sub message carries no business data. Only the three pointer IDs cross the wire (section 3); the runner re-reads everything from the database under the job's organization scope.
  • The strategy never leaves the database. There is no file output and no bucket β€” the strategy is a SolutionStrategy row in the org-scoped Postgres schema, with no signed URLs or external artifacts to secure.
  • Real-time events stay within the organization. The solutionStrategyChanged event is fanned out only to clients in the publishing organization, following the platform's subscription-payload conventions.
  • The agent is constrained to grounded inputs β€” and the constraint is enforced twice. Its system prompt forbids recommending categories, vendors, product families, or service classes absent from the compiled context, forbids excluded vendors and unconfirmed delivery motions, and forbids inventing identifiers (ID fields must carry bare cuids drawn from the context). Because a prompt-level control can be ignored, the deterministic correctors and scorers re-check the same rules against the snapshot's lookups after generation β€” repairing what is repairable and surfacing the rest as findings. Untrusted deal content is supplied as context data the agent reasons over, never as instructions.
  • Query-cost budgets apply. The mutation and its companion subscriptions run under the API's global GraphQL depth/complexity/token limits.

12. Performance & Cost Controls

Solution strategy generation is lighter than proposal generation, but the refinement loop means it is no longer a single-call workflow β€” so it is bounded on several axes.

  • Full process isolation. The deployed push path gives each run its own Cloud Run Job execution (4Gi container, 2560MB V8 heap, 1-hour task timeout), sized for the nested deal-context refresh it may perform, so no HTTP request is held open and a slow run never ties up request capacity.
  • The refresh is conditional and bounded. It runs only when requirements have actually drifted, and its requirement-change shape skips asset analysis entirely β€” one synthesis agent call rather than a full re-ingestion.
  • Inputs are read once, up front. The runner compiles the entire solution context before the workflow starts, so the steps issue no ad-hoc aggregation reads mid-run. The compile is version-collision-safe and reused as the scorers' lookup source.
  • Refinement is cost-bounded and stops early. The revise budget caps the passes; the quality bar, unrouteable-findings and plateau conditions usually stop it sooner; best-round ties resolve to the earliest round; and the judge is skipped entirely when refinement is disabled or the context won't parse.
  • The evaluation is mostly free. Eleven of the fifteen rules are pure code over an already-loaded snapshot β€” no tokens. Only the four subjective ones cost a model call.
  • Bounded agent calls. Every judge run has a 10-minute ceiling with at most one corrective re-ask; stall-guard exhaustion is not retried. The generation and revise calls run under the shared stall guard with its 13-minute wall-clock ceiling and idle-liveness window.
  • Step-level short-circuit and selective retry. Each visible step records its result and short-circuits on completedAtUtc; a retry re-runs only the steps that hadn't completed. The loop is the deliberate exception β€” it is recomputed rather than checkpointed.
  • No rendering or upload tail. Because the output is a database row and not a rendered document, there is no DOCX rendering, GCS upload, or signed-URL minting cost on the success path.

Summary

End-to-end the flow looks like:

End-to-end message flow Web UI API Pub/Sub β†’ dispatcher Cloud Run Job Generate strategy user action on a deal initiateSolutionStrategyGeneration (dealId) Kickoff (resolver) validate preconditions create job (Pending) no lock Β· no placeholder publish message 3 pointer IDs Β· no business data job handle returned Push β†’ dispatcher <env>.deal-output .generate-solution-strategy OIDC-gated POST Β· CAS claim start Job execution container overrides Refresh requirements only if drifted nested deal-context run Compile snapshot re-check approved reqs SolutionContextSnapshot generate β†’ evaluate correct Β· score Β· judge ≀ 2 revise passes store best round SolutionStrategy vN+1 Redis pub/sub β€” job status Β· solutionStrategyChanged Β· activity Live UI updates review Β· override Β· approve
From click to live update: the mutation enqueues, Pub/Sub push hands off to the dispatcher, a dedicated Cloud Run Job refreshes, generates, self-scores and stores a new version, and Redis events close the loop β€” no polling, no refresh.