overview / agentic-workflows / 04-proposal-generation

Proposal Generation Workflow

Last updated at 2026-07-30 AEST.

A high-level walkthrough of how the platform turns a user's "Generate proposal" click into a finished Word document attached to the deal β€” consuming the approved solution version produced by the upstream solution-generation workflow and assembling it, section by section, into an enterprise proposal.

At a glance

A handful of numbers capture the shape of the workflow β€” how it's structured, how it parallelizes, and how it keeps a stalled run from wedging:

18
workflow progress steps
12
proposal sections in the document
10
section agents that run here (2 arrive upstream)
8
section agents in one parallel block
6
rubric dimensions, scored 0–100
5 β†’ 13 min
per-agent timeout ceilings

Overview

This is one of the platform's most involved pieces of machinery: a long-running, multi-agent document-generation pipeline that turns structured deal data into a finished enterprise proposal. The characteristics below are grounded in the mechanisms documented in the rest of this file β€” they describe what the code actually does, not aspirations.

Two-phase by design

The proposal reuses the approved upstream solution β€” it loads a persisted solution version (the structured solution model, the solution-overview section, the current-state section, and pre-rendered architecture diagrams) produced earlier by the solution-generation workflow via a shared load step, rather than re-running the design pipeline. A missing solution fails the run fast and deterministically rather than regenerating inline.

Gated on an approved solution version

The entry gate keys solely on the deal's approved SolutionModel β€” at most one per deal β€” which must be linked to a strategy and non-stale on both axes. The proposal then binds to that exact version, not the latest, so what it renders is what was approved.

Large-scale and multi-agent

An 18-step Mastra workflow orchestrating ten dedicated section agents, an engagement-plan agent, and a multi-run evaluation judge β€” with independent sections fanned out in parallel and dependent steps in sequence.

Runs off-request, in its own process

The mutation does a small validated, transactional kickoff and hands off to Google Cloud Pub/Sub. In every deployed environment the minutes-long generation runs in a dedicated Cloud Run Job execution, never holding an HTTP request open or competing for API capacity.

Idempotent and resumable

Each step caches its output keyed on its real inputs; retries short-circuit completed steps, the job is claimed atomically, and a per-deal Postgres advisory lock keeps version numbering race-free.

Robust failure handling

Failures are classified deterministic versus transient, and the two execution paths route them oppositely (see Failure handling); every agent call is timeout-bounded and stall-guarded.

Self-healing

A periodic recovery sweep plus an on-startup pass fail wedged jobs via an idempotent compare-and-swap, cancellation is checked at multiple points, and an execution that dies uncatchably is terminalized out-of-band from Cloud Run's own failure event.

Quality-controlled

A pre-flight quality-gate review on the bound solution version flags thin-input proposals as drafts (generation still proceeds, and the output is watermarked and locked), and a config-gated self-refining loop judges the document on a six-dimension rubric and re-runs the weakest proposal-authored sections.

Traceability and auditability

Every run pins an immutable ProposalContextSnapshot, the finalized DealOutput records full lineage (strategy, snapshot, solution model, deal context), agent calls write Generation audit rows, and judge rounds persist as ProposalEvaluation rows.

Multi-tenant and real-time

All data is scoped to the caller's organization, and every state transition is published over Redis pub/sub so the new job, the placeholder, and the finished proposal appear live on every open client tab.

Security and correctness

The evaluation judge treats all customer- and model-supplied content as untrusted data and is hardened against prompt injection; agent output shapes derive from Zod schemas as a single source of truth, and the engagement plan is reconciled against a deterministic skeleton so the agent can't silently drift its structure.

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

1. Entry Point

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

  • Mutation: initiateProposalGeneration

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

2. Input Required

The mutation accepts a small InitiateProposalGenerationInput:

FieldRequiredPurpose
dealIdYesThe deal the proposal is being generated for.
customInstructionsNoFree-text guidance threaded into the section agents' prompts to steer generation.
documentStyleIdNoPins an organization document style whose configuration (cover mode, styling) steers the rendered document.
shouldForceRegenerationNoBypasses the per-step result cache on retry so every section is re-run from scratch.

The logged-in user is taken from the auth context β€” the resolver never trusts a caller-supplied user or organization ID. String fields are trimmed at the boundary via TrimPipe before validation runs.

Preconditions

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

  1. The deal exists and belongs to the caller's organization.
  2. The document style, when one is pinned, exists and belongs to the caller's organization.
  3. The deal has at least one deal requirement.
  4. The deal has at least one deal context.
  5. The deal has an approved SolutionModel version.
  6. That approved version is linked to a solution strategy β€” the strategy the proposal will load is derived from the model, not picked independently.
  7. That approved version is not stale: neither the requirement set nor the deal context has moved past what it was grounded on, and no newer strategy version exists.
  8. No other Solutioning generation is already running for the deal. Strategy, solution and proposal generation are serialized one-at-a-time per deal, so a second run is refused with a sentence naming the type already in flight rather than queued behind it.

The gate moved. The proposal no longer keys on "an approved solution strategy that isn't stale" β€” it keys solely on the approved SolutionModel version, which is the exclusive at-most-one-per-deal approval. Strategy approval now gates solution generation instead (see Solution Strategy). The staleness rule itself is a single shared helper (computeSolutionModelStaleness) called identically here and by the workflow's load step, so the two can never drift.

If any check fails, the mutation throws a BadRequestException with a human-readable reason β€” nothing is queued. One precondition is deliberately not checked here: the existence of the persisted solution row itself. That gate lives inside the workflow (load-persisted-solution-step, section 6), which fails deterministically with a user-actionable message.

The same gate is re-asserted on every later delivery of the job, not only on the button click β€” a manual retry and an automatic Pub/Sub redelivery both run it. A proposal run is pinned to its solution version at enqueue and the load step honours that pin without re-checking staleness, so a redelivery could otherwise render a superseded solution as the current proposal even with no completed work behind it. When the approved version has moved (or the run carries no pin at all), the delivery is refused with the same sentence the mutation would have thrown, classified as deterministic so the message is acknowledged rather than redelivered against a condition that cannot clear by itself. Clients can read that sentence ahead of time from AsyncProcessingJobModel.resumeBlockedReason, so a terminal job explains why it cannot be resumed before the user tries.

A second, separate check then asks whether another generation is already holding this deal: the rule that a deal runs one generation at a time is re-asserted at the moment a worker claims the job, not only when someone starts one. A delivery that finds the deal held is refused permanently, with its own distinct message β€” not the sentence above β€” telling the reader to start the proposal again once the generation holding the deal has finished. This is what stops a queued message that was abandoned long enough to be treated as gone, and then finally delivered, from running alongside the generation that replaced it.

The "kickoff" transaction

Once the preconditions pass, the resolver runs a single database transaction that does three things atomically:

  1. Creates an AsyncProcessingJob of type GenerateProposal, linked to the deal, starting in Pending.
  2. Creates a placeholder DealOutput of type ProposalDocument with the next sequential version number, linked to the job. There is no explicit "state" column β€” the row simply has no assetId yet and points at an in-progress job, which is what lets the UI show a "Generating proposal v3…" row the instant the mutation returns. The lookup is idempotent (keyed on asyncProcessingJobId), so a retried kickoff reuses the existing placeholder.
  3. Advances the deal stage to ProposalReview if it isn't already there (stage movement is monotonic β€” a deal already at ProposalReview or DeliveryHandoff is left alone).

A Postgres advisory lock (pg_advisory_xact_lock, keyed on the deal + output type and held only for the transaction) serializes any concurrent proposal-generation kickoffs for the same deal, so the version numbering can never race.

After the transaction commits, the resolver publishes three real-time events through Redis pub/sub so every connected client (the deal page, the proposals card, sibling tabs) sees the new job and placeholder appear immediately:

  • asyncProcessingJobStatusChanged (CREATED)
  • dealOutputChanged (CREATED β€” only when the placeholder was newly created)
  • dealChanged (UPDATED β€” only when the stage actually advanced)

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 in-process background listener.

  • Topic: <environment>.deal-output.generate-proposal-document (the literal name is environment-prefixed and resolved per environment via env vars; e.g. api-1ke-local-au.deal-output.generate-proposal-document)
  • Schema: proposalGenerationPubSubMessageDataSchema

Payload:

{
  "correlationId": "<asyncProcessingJobId>",
  "payload": {
    "asyncProcessingJobId": "...",
    "dealId": "...",
    "dealContextId": "...",          // latest version snapshot
    "solutionModelVersion": 7,       // the APPROVED version to bind to
    "solutionStrategyId": "...",     // derived from the approved model's link
    "userId": "...",                  // who triggered the run
    "customInstructions": "...",     // optional
    "documentStyleId": "...",        // optional
    "shouldForceRegeneration": false  // optional
  }
}

solutionModelVersion is the load-bearing addition: the load step selects that exact version rather than the latest, so the proposal grounds on approved content even if a newer (unapproved) solution version has since been generated.

The message is tiny β€” it's just enough pointers for the runner to find everything it needs in the database, and optional fields are omitted entirely rather than sent as null. No business data is carried on the wire.

4. How the Message Is Executed

There are two execution paths, selected per workflow by the WORKFLOW_PUSH_JOBS_ENABLED env var. Proposal generation is in that set in dev and both production regions, so the push path is the deployed reality; the pull path is what runs locally.

  • 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) before starting anything β€” so a redelivered message finds it already claimed and no-ops β€” then launches a dedicated Cloud Run Job execution, stamping the workflow type and the verbatim base64 message data as container-override env vars. That Job is sized for this workload specifically: a 4Gi container with a --max-old-space-size=2560 V8 heap, because finalizing the assembled document ends in a large native UTF-8 buffer encode (a JSON.stringify of the full document) that must fit off-heap alongside the enlarged heap. 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 validates the envelope (acking and dropping a malformed one, which would fail forever on replay), claims the job with the same compare-and-swap β€” plus Failed β†’ InProgress when the subscription has a dead-letter policy, so DLQ replays can re-claim β€” and drives the same shared runner. Its concurrency and ack-deadline / lease-extension windows are env-bounded and sized for long-running LLM work.

What the shared runner does

The workflow body lives in ProposalGenerationRunnerService and is driven identically by both paths:

  1. Load the inputs the workflow needs (see next section); a deal with no requirements fails here.
  2. Run the pre-flight quality-gate review against the bound solution version β€” the one carried on the payload, not the latest β€” so the gate result matches the content the proposal will actually render. A fresh existing SolutionModelQualityGateReview row is reused (e.g. from the user's on-demand check); otherwise one is computed and upserted so the SolutionModel carries the same review the proposal was gated on. isDraft is true only when the review explicitly failed (a null review means "gates not evaluated", not draft); generation still proceeds either way, and the flag is stamped on the finalized DealOutput (section 7). This pre-flight review replaced both the old inline pre-flight and the removed in-workflow quality-gates step.
  3. Initialize or restore WorkflowProgress plus one WorkflowStep row per step (18 in the canonical order, derived from the workflow definition rather than hand-listed). On retry, only the not-yet-completed steps are reset.
  4. Compile the proposal context into a ProposalContextSnapshot (an immutable, point-in-time aggregation of everything the agents will read).
  5. Compute the output file name and GCS path up-front, before the run starts, so a retry or cache-hit re-render overwrites the same object rather than orphaning a new one.
  6. Stage the refinement configuration (enable flag, max iterations, in-loop judge run count, score threshold, severity gate) plus the Nest service handles the commercial-model step needs into the workflow's request context β€” Mastra can't read Nest config or DI directly, so the steps read these from the request context at run time.
  7. Start the Mastra workflow run (see section 6), surfacing the run handle so a cancel β€” or a Job SIGTERM β€” can abort an in-flight step. The workflow itself renders and uploads the DOCX to Google Cloud Storage and returns the stored file path.
  8. On completion, record the Asset, finalize the placeholder DealOutput, and log a ProposalGenerated activity in a single transaction, then persist the per-round refinement evaluations, auto-enqueue a standalone evaluation, and publish the final events (see section 7).

Shutdown semantics differ by path, deliberately. The pull subscriber registers a handler that reverts an in-flight job to Pending on SIGTERM. The Job path does the opposite: it aborts the run and exits non-zero, leaving the row InProgress so Cloud Run task-retry resumes from the per-step WorkflowStep checkpoints.

Failure handling

Failure-handling decision tree Pub/Sub message processing deterministic transient cancelled ack + mark Failed replay would reproduce it retry, from checkpoints pull: nack Β· push: exit 1 clean exit no failure flip retry budget exhausted terminal failure DLQ (pull) Β· 3 task retries (push)
How a run that doesn't complete cleanly is routed: fail fast, retry, or exit.
  • Deterministic failures (schema violations, contract bugs, a missing persisted solution β€” surfaced as DeterministicWorkflowError) mark the job failed and stop. On pull the message is acked; on push the process exits 0, because retrying would just reproduce the same error.
  • Transient failures (network, provider blip, OOM) retry. On pull the job reverts to Pending and the message nacks β€” Pub/Sub redelivers up to the configured retry budget, then routes it to a dead-letter topic whose subscriber surfaces it for human review. On push the row stays InProgress and the process exits non-zero so Cloud Run task-retry (3 retries, a 1-hour per-task timeout) resumes from the WorkflowStep checkpoints β€” the Job must not revert.
  • Job cancellation is checked at multiple points; a cancelled job exits cleanly without flipping to failed.
  • Uncatchable deaths β€” a V8 heap-OOM abort, an OOM-killer SIGKILL, a boot crash β€” kill the process before any handler runs. A Cloud Logging sink routes Cloud Run's terminal execution-failure event to a Pub/Sub topic whose OIDC-guarded push subscription hits POST /internal/workflow-jobs/execution-failed on the API, which resolves the execution, recovers the job id from the container override, and terminalizes the row out-of-band.

5. Data Retrieved for the Run

Before the workflow runs, the runner assembles the heavy inputs up front; two loader steps inside the workflow then pin the deal context and the bound solution version at the start of the run:

SourceWhat's read
DealCustomer name + ID, deal name, organization ID.
DealRequirement (many)The full list of in-scope requirements (category, priority, text).
DealContextThe latest captured context for the deal (or the version pinned by ID), loaded by fetch-deal-context-step.
SolutionStrategyThe strategy the approved solution version links to, plus its userSelections overrides: excluded service offering IDs, delivery-motion toggles, and the selected solution path per category (vendor + product families).
SolutionModel (the approved version)The bound solution version β€” the structured solution model, the solution-overview section, the current-state section, and the pre-rendered architecture-diagram assets β€” loaded by load-persisted-solution-step.
SolutionModelQualityGateReviewThe bound version's gate verdict, reused if fresh or computed and upserted, which decides isDraft.
CommercialModelThe bound version's own 1:1 priced model, read (or built as a safety net) by build-commercial-model-step so the proposal narrates the same numbers the Commercials view shows.
ProposalContextSnapshotBuilt fresh on every run β€” see below.
ProposalBoilerplateThe organization's enabled boilerplate library, spliced in near the end.
DocumentStyleThe optionally-pinned style, resolved to a style configuration (cover mode, styling) for the document renderer.
OrganizationAsset (logo)The selling organization's own logo, rendered on the proposal cover page (optional).

The proposal context snapshot is the heaviest piece. It's compiled by the ContextCompilerService and pulls together (per proposalContextPayloadSchema):

  • The selected service offerings with their commercial attributes (service class, pricing basis / model, whether a rate card or effort template exists).
  • The selected solution resolved from the approved strategy β€” vendors, product families, and categories.
  • The commercial structure (one-off vs. recurring items, rate-card requirement) and completeness indicators (institutional-profile, commercial-profile, and pricing completeness, plus a runtime-confidence score).
  • The merged set of excluded service offerings (from prior snapshots + current user selections).
  • A reference to the institutional overlay version the strategy was generated against.

The snapshot is persisted to Postgres so the exact inputs that produced a given proposal version can be replayed later if needed.

6. How Data Flows Through the Workflow

The workflow itself is a Mastra workflow (proposalGenerationWorkflow) composed of 18 progress steps. Some run sequentially, others in parallel where they have no data dependency on each other. One of the 18 β€” the self-refining loop β€” is a single opaque progress step that internally wraps an evaluate β†’ revise β†’ re-evaluate cycle (see "The self-refining loop" below).

Step graph (top to bottom)

Proposal generation step graph Fetch deal context pins the exact deal-context version Load persisted solution model Β· overview Β· current state Β· diagrams shared loader Β· fails fast when missing Postgres persisted SolutionModel the approved version Build commercial model reads the bound version's priced model builds only if it was never built Engagement plan generation code skeleton Β· agent sets timing only 8 section agents β€” run in parallel commercials implementation plan managed services migration & cutover NFR coverage risks & assumptions security & compliance testing & acceptance engagement-mix-conditional sections skip cleanly with a null result Appendices generation largest input Β· 13-min ceiling Executive summary generation section agent Generate proposal JSON stitches sections in canonical order Refinement loop evaluate β†’ revise β†’ re-evaluate single opaque step Β· no-op when disabled Inject boilerplate sections org boilerplate Β· fingerprint-cached Generate proposal document renders the .docx in memory uploads to GCS Β· returns the file path GCS org's private bucket
The 18-step pipeline. Purple = LLM agent work, blue = deterministic code, grey = datastores. The solution model, the solution-overview and current-state sections, and the architecture diagrams all arrive pre-generated from the solution-generation workflow.

What each step does

  • Fetch deal context (fetch-deal-context-step) β€” pins the exact deal context version the run will use (the version pinned by ID, else the latest). Shared with the solution and solution-document workflows.
  • Load persisted solution (load-persisted-solution-step) β€” the shared loader. It selects the exact solution version carried on the payload (the approved one) rather than the latest, and loads its structured solution model, its solution-overview section, its current-state section, and its pre-rendered architecture-diagram assets (durable GCS paths, ordered). A missing version is a deterministic failure with a user-actionable message β€” there is deliberately no inline regenerate fallback. The loader also carries a staleness hard-gate for callers that want it, but it is skipped when an explicit version is pinned (as it always is here, because the resolver already gated on staleness before enqueuing). A null or malformed overview / current-state is tolerated as "no such section" rather than a hard failure. The step stamps solutionModelId onto the run for downstream cache keys.
  • Build commercial model (build-commercial-model-step) β€” read-for-this-version, build-if-absent. Each finalized solution version owns its own 1:1 CommercialModel, built eagerly when the version was generated; this step reads the one belonging to the version this run loaded, so the proposal-context compiler projects it and the commercials agent narrates the same numbers the Commercials view shows. An already-built model is reused as-is β€” never rebuilt, which would discard the user's review and override work. It only builds when the eager step never ran for this version, so generation proceeds with clearly-flagged incomplete commercials rather than failing. A freshly-built model publishes commercialModelChanged; a reused one is unchanged, so it doesn't.
  • Engagement plan generation (engagement-plan-generation-step) β€” builds a deterministic skeleton from the solution model's professional-services work packages, then calls an agent purely to reason about phase durations and sequencing (the agent may not add, drop, or rename phases β€” its output is reconciled against the skeleton or the step fails). A deal with no professional-services work yields a null engagement plan and the agent call is skipped entirely.
  • Section generation steps β€” ten dedicated section agents run in this workflow: commercials, implementation plan, managed services, migration & cutover, NFR coverage, risks & assumptions, security & compliance, and testing & acceptance (eight in parallel), then appendices and executive summary (in parallel). Each step is named {section}-generation-step and calls a dedicated AI agent with a tailored system prompt, a per-section slice of the deal context, the solution model, the proposal context snapshot, the engagement plan, and the user's custom instructions. Each agent returns a structured section in our internal document model format (a JSON tree of sections, blocks, and runs), and most are given a fixed, deterministically-enforced sub-section outline plus a bullets-versus-tables policy. Sections conditional on the engagement mix β€” implementation plan, migration & cutover, and testing & acceptance need professional services; managed services needs a managed-services motion; NFR coverage needs either β€” complete cleanly with a null result and no agent call when their condition isn't met.
  • Two of the twelve document sections are not generated here. currentState and solutionOverview are solution-owned: they arrive pre-generated with the loaded solution version and are consumed verbatim. They stay visible to the evaluation judge so conformance can be scored, but no proposal step β€” including a refinement revise pass β€” ever regenerates them.
  • Generate proposal JSON (generate-proposal-json-step) β€” concatenates all the per-section document models into a single proposal document JSON in the canonical section order, stamping each section's position. When this step is served from cache (every cache-key input unchanged), it flags the run so the refinement loop can skip re-judging byte-identical content.
  • Refinement loop (refinement-step) β€” an optional self-refining cycle (see "The self-refining loop" below) that scores the assembled document and re-runs the weakest sections until the quality bar is met or a stop condition trips.
  • Inject boilerplate sections (inject-boilerplate-sections-step) β€” splices in non-AI-generated boilerplate (e.g., standard T&Cs, company background) from the organization's library: enabled ProposalBoilerplate rows, org-scoped, in display order, each anchored before/after a named section or positioned globally. The step's cache is additionally keyed on a fingerprint of the enabled boilerplate set, so editing boilerplate re-runs injection even when everything else is cached.
  • Generate proposal document (generate-proposal-document-step) β€” resolves each embedded diagram's durable GCS object path into real image bytes (reading its PNG document sibling), renders the document JSON to a .docx file in memory using a custom document-model-to-DOCX renderer, applies the pinned document style's configuration (cover mode, styling) when one was provided, then uploads it to the runner-computed Google Cloud Storage path and returns the stored file path (see section 7). The cover page pulls in the customer name, deal name, and generation date, plus the selling organization's own logo (falling back to the 1KEβ„’ platform logo when none is set); the footer carries "customer – deal", a "Β© 1KE. Commercial in confidence." notice, and the page number. When the run is flagged isDraft, the rendered file additionally gets a DRAFT watermark and Word read-only document protection applied to the packaged .docx, so a gate-failing proposal can't be mistaken for a shippable one.

Caching, retries, and progress

  • Every step writes a WorkflowStep row with started, completed, or failed timestamps, publishing a status event on each transition. This is what powers the live progress bar the frontend shows β€” the user sees per-step progress messages ("Generating commercials…", "Refining proposal…", "Generating proposal document…") the entire time.
  • Two-tier in-step retry: a section agent whose output fails to parse as JSON, or fails schema validation, gets exactly one retry of that tier β€” at most two LLM calls per section per pass.

Most steps cache their output keyed on (dealContextId, solutionStrategyId, proposalContextSnapshotId, solutionModelId, customInstructionsHash). On retry, a step that already completed against the same inputs is short-circuited β€” only the failed steps re-run. The boilerplate-injection step adds a fingerprint of the enabled boilerplate set to its key, so boilerplate edits re-run injection even on an otherwise full cache hit.

The self-refining loop

After the document JSON is assembled, an optional refinement loop tries to raise quality before the document is rendered. The runner stages its per-environment configuration into the workflow's request context before the run starts (section 4). When disabled, the loop is a no-op pass-through and the run behaves exactly as it did before refinement existed.

When enabled, the loop works like this:

The self-refining loop assembled document JSON output of generate-proposal-json Evaluate β€” LLM judge multi-run judge Β· 6-dimension rubric Stop or continue? bar Β· routability Β· budget Β· plateau routable HIGHs remain Revise weak sections re-runs only flagged sections re-evaluate β€” next round stop condition met Keep the best round fewest HIGHs β†’ highest score β†’ earliest restored into the document JSON then boilerplate injection + render
Purple = LLM agent work, blue = deterministic code, green = the terminal best-round selection. Round snapshots live in workflow state, not in the step cache.
  1. Evaluate (round 0). The LLM judge scores the freshly assembled document against the rubric, emitting an overall score plus a list of findings (each with a severity, a dimension, and the section it applies to β€” or * for cross-section issues). The judge is run multiple times per round (the configured in-loop run count) and the runs are merged deterministically: dimension and overall scores are averaged, duplicate findings collapse with the worst severity winning, and coverage entries keep their worst status.
  2. Decide whether to continue. The loop stops as soon as any of these holds: refinement is disabled; the latest round clears the quality bar (zero HIGH findings and score at/above threshold); none of the remaining HIGH findings is routable to a section a revise pass could actually fix; the revise-pass budget (maxIterations) is spent; or the round plateaued (improved neither the HIGH count nor the score versus the prior round).
  3. Revise. Each revise pass re-runs only the sections that own a routable HIGH finding, writing the new section output to the round's state (not to the cached WorkflowStep rows, and with no Generation audit rows), then re-evaluates to produce the next round. Routable means the ten proposal-authored sections only. The two solution-owned sections (currentState, solutionOverview) are excluded: they are the fixed reference the judge scores against, and no proposal step regenerates them β€” so a HIGH finding on them means "regenerate the solution", not "burn a revise pass". Findings on them therefore stop the loop rather than driving it.
  4. Keep the best round. Across all rounds, the best is the one with the fewest HIGH findings, then the highest overall score (ties resolve to the earliest round, sparing already-spent agent calls). That round's section outputs are restored into the document JSON before boilerplate injection.

One step on the outside, a loop on the inside. The whole cycle surfaces to the UI as a single opaque "Refining proposal…" step; the inner evaluate/revise steps use IDs that are invisible to the progress indicator, and per-round detail is captured inside the opaque step's result. The per-round judge evaluations are persisted as ProposalEvaluation rows after the workflow completes (section 7), so the refinement history of a given proposal version is inspectable without re-running the judge.

The evaluation rubric

The judge is a single LLM agent (proposalEvaluationAgent) that scores the generated proposal against the inputs that fed its generation β€” the deal inputs (meeting transcripts, RFPs, emails, compliance documents, SOWs, sizing spreadsheets, etc.), the approved requirements, the compiled deal context, and the compiled proposal context β€” and against itself for internal congruence. The same rubric powers the refinement loop's in-line judging, the standalone proposal-evaluation capability, and the evaluation automatically enqueued after generation; the schema is the single source of truth (packages/common/src/schemas/proposal-evaluation/), and every persisted evaluation is stamped with the rubric version so historical scores stay attributable when the rubric evolves.

The judge scores six dimensions, each 0–100 (higher is better). It scores conservatively β€” scores above 85 are reserved for content clearly traceable to inputs with no contradictions or missing signal:

DimensionWhat it measures
faithfulnessEvery named entity, number, date, quoted commitment, scope item, and pricing figure must trace back to the inputs. Vendors/products/services not in the proposal context are hallucinations and warrant a HIGH finding. The judge is given the same fact ledger the writing agents wrote from β€” the approved solution strategy, solution model and engagement plan β€” so an architecture relationship, NFR target, security control or phase that reproduces a ledger entry is grounded by definition and is not a finding; without those payloads the judge scored faithful reproduction as invention, which also forfeited the specificity credit.
coverageEvery meaningful input signal (requirements, pain points, success criteria, deadlines, integration points) and every accepted requirement must be addressed somewhere in the proposal.
internalCoherenceCross-section consistency β€” the vendor/product mix, priced scope, and timeline must agree across architecture, solution overview, commercials, implementation plan, and managed services.
tonePositioningOne consistent voice and stance toward the customer; the customer is referenced respectfully and with consistent naming; no unsupported marketing language. Also scored: unearned superlatives with no named mechanism behind them; first-person-plural vendor voice; a generic label ("the client", "the customer", "the end user") used as a standing substitute for the customer's actual name, while second person ("you", "your") is house style and is not a defect; one name per entity across sections; workstream references in the WS{n} β€” {name} form; and an in-sentence list running to four or more items in body prose or a bullet, which house style caps at three (a table cell that exists to inventory items is exempt β€” a cell is a container, not a sentence). Deliberately not scored: bullet style, sentence length, paragraph counts, and list nesting β€” these are formatting preferences, not evaluable quality, and that exclusion does not reach the enumeration cap, which is countable.
structuralCompletenessNo stray placeholders, empty blocks, or off-deal boilerplate; every section required for the engagement mix is present and non-trivial. One sanctioned exception: a [TBC: …] marker inside a table cell marks a genuinely unknown descriptive value and is not reported. The same marker in a paragraph, bullet, or heading is a defect, as is one occupying a fixed-vocabulary cell (a Commercials money column, a Likelihood/Severity cell, or an RTM Coverage Status cell).
solutionSpecificityTechnical depth of the recommended solution relative to what the inputs make achievable. Component/SKU-level depth tied to requirements scores high; vague language where depth was achievable scores low and is HIGH-eligible.

Two rules keep the dimensions honest:

  • Faithfulness vs. specificity can't be played against each other. Specificity is credited only when traceable to the proposal context's selectedSolution or the catalog β€” invented component-level depth is a faithfulness HIGH finding, never a specificity credit.
  • Achievable-ceiling rule. When the upstream selectedSolution carries no product-family depth (productFamilyIds is empty), a vendor/category-level proposal is the correct ceiling β€” it is scored on appropriateness at that level, not penalized for depth the inputs can't support. The refinement loop encodes the same judgment in code: a HIGH solutionSpecificity finding against an impoverished upstream solution is annotated as upstream-caused and treated as unrouteable, stopping the loop rather than burning revise passes on it.

Alongside the scores, the judge emits:

  • A coverageReport β€” one entry per material source signal, each tagged fully / partial / unaddressed with the sections that address it and a reference back to the source (deal-input id, requirement id, or file name).
  • A list of findings β€” each with a dimension, a severity (low / medium / high), the sectionId it applies to (or * for cross-section issues), an optional blockId, the claim (the proposal text that surfaced the issue), the evidence from the inputs, and a concrete suggestion. HIGH severity is reserved for hallucinations, scope/pricing inconsistency, missing required sections, unaddressed compliance/security requirements, and genuinely vague solutions where greater specificity was achievable. Style and tone findings are capped at medium: a house-style breach depresses the tonePositioning score but can never on its own route a section to a revise pass, so it cannot crowd a faithfulness or coverage fix out of the revise budget.

The overallScore is the equal-weighted mean of the six dimension scores, and each dimension's score must be consistent with the severity of its own findings (a dimension with multiple HIGH findings cannot also score above ~70). The refinement loop's stop logic keys off exactly these outputs: the HIGH-finding count and the overallScore versus the configured score threshold.

7. Where the Generated Document Is Stored

The DOCX is rendered and uploaded to Google Cloud Storage inside the final workflow step, into the organization's private bucket. The path and file name are computed by the runner before the run starts (kebab-cased, ISO-timestamped), so a retry or cache-hit re-render overwrites the same object rather than orphaning a new one:

organizations/<organizationId>/deals/<dealId>/<customer>-<deal>-proposal-<timestamp>.docx

The workflow then returns the stored file path, the file size, the full documentJson, the run's lineage IDs, and the per-round refinement history (it does not return the DOCX bytes). The runner takes it from there:

  1. Creates an Asset row in Postgres recording the bucket, file path, file size, original file name, and isSystemGenerated = true.
  2. Finalizes the placeholder DealOutput (by asyncProcessingJobId, so the version assigned at kickoff stays stable β€” the update asserts it matched exactly one row) in the same transaction:
    • Points it at the new Asset (assetId).
    • Stores the structured documentJson (content) alongside the file reference.
    • Records the solutionStrategyId, proposalContextSnapshotId, solutionModelId, dealContextId, and isDraft flag so the exact lineage of this version is fully traceable.
  3. Logs a ProposalGenerated activity (Activity + DealActivity) in the same transaction, so an observable proposal always has its history entry.
  4. Persists the refinement history (when the loop ran) as ProposalEvaluation rows β€” one per judge round, tagged with the RefinementLoop source and carrying the judge model, run count, rubric version, scores, findings, and coverage report. The write is idempotent across retries: the output's prior RefinementLoop rows are deleted and the current run's rounds re-inserted in one transaction (manually-created evaluations are untouched).
  5. Auto-enqueues a standalone proposal evaluation for non-draft outputs (using the configured final judge run count), so every shipped proposal version gets a full post-generation scorecard; a failure here is logged but never fails the generation.
  6. Publishes the final events β€” dealOutputChanged (UPDATED) so every open client tab swaps the "Generating…" row for the real downloadable proposal, activityChanged (CREATED) for the history entry, and the job's transition to Completed.

If the upload succeeded but the finalization transaction rolls back, the runner best-effort deletes the orphan file from GCS so storage doesn't drift from the database.

For download, the client requests the DealOutput's download field and the backend resolver mints the signed URL to the GCS file (via the file-storage service) β€” the client never signs URLs itself. Section 8 covers the signed-URL properties.

8. Security

The flow handles customer-supplied content and produces a customer-facing document, so security applies at the API boundary, on the wire, in storage, and inside the agents themselves.

  • 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 validated at the boundary. InitiateProposalGenerationInput is a validated DTO (string fields trimmed via TrimPipe before validation), and the resolver enforces the preconditions in section 2 β€” including org-scope checks on the deal and the pinned document style β€” before anything is queued, so a request that can't produce a real proposal is rejected synchronously rather than failing deep in the background run.
  • The dispatch hop is authenticated and minimally privileged. Every workflow-dispatcher route is gated by GoogleOidcGuard against a fixed audience, so only the allowlisted Pub/Sub push service account can invoke it. The dispatcher performs only a slim compare-and-swap claim, so it receives just the database-connection secret β€” never the API's full secret set.
  • The Pub/Sub message carries no business data. Only pointer IDs cross the wire (section 3); the runner re-reads everything from the database under the job's organization scope, so there is no customer content in the payload to leak or tamper with.
  • Generated documents live in private storage. The DOCX is written to the organization's private bucket under an org- and deal-scoped path, and the Asset is marked isSystemGenerated. Downloads are served exclusively through short-lived, read-only V4 signed URLs that expire after a bounded validity window; the signing path also asserts no path-traversal segments before minting a URL (the check runs even on cache hits). Clients never get direct or durable access to the bucket.
  • Real-time events stay within the organization. The asyncProcessingJobStatusChanged / dealOutputChanged / dealChanged / activityChanged events are fanned out only to clients in the publishing organization, following the platform's subscription-payload conventions.
  • The evaluation judge treats supplied content as untrusted. Its system prompt explicitly designates every customer- and model-supplied payload β€” wrapped in named XML tags (<proposal-document>, <deal-context>, <proposal-context>, <solution-strategy>, <solution-model>, <engagement-plan>, <deal-inputs>, <deal-requirements>, …) and delivered in the user message, never the system slot β€” as data, not instructions. It ignores any embedded instruction, role-override, or scoring directive, refuses to alter its rubric or output format, and raises a high-severity faithfulness finding pointing at the offending section when a payload looks like an injection attempt rather than silently complying.
  • Query-cost budgets apply. The mutation and its companion subscriptions run under the API's global GraphQL depth/complexity/token limits; the signed-URL download field carries an explicit complexity weight so its per-row signing cost is priced into the budget.

Honest scope of the injection hardening: this is a prompt-level control (a <security_preamble> in the judge's system prompt, with the untrusted payloads wrapped in named XML tags), not code-enforced input sanitization β€” and it applies to the evaluation judge specifically, not the section-generation agents.

9. Performance & Cost Controls

Proposal generation is the platform's heaviest single operation β€” a dozen-plus LLM calls per run β€” so the design is built around keeping wall-clock latency, memory, and LLM spend bounded.

  • Asynchronous offload. The minutes-long generation runs in a background Pub/Sub listener rather than under the triggering request (sections 3–4), so no HTTP request is held open and a slow run never ties up request capacity.
  • Expensive work is done once, upstream. The solution model, the solution-overview and current-state sections, and the architecture diagrams are generated and rendered by the solution-generation workflow and merely loaded here β€” a proposal run makes no diagram calls at all and two fewer section-agent calls, and regenerating a proposal doesn't re-pay for any of it.
  • The commercial model is read, not rebuilt. The bound solution version's priced model is reused as-is; it is only built here when the eager upstream build never ran for that version.
  • Parallel fan-out. Sections with no data dependency on each other run concurrently β€” the eight mid-document sections in one parallel block and appendices + executive summary in another β€” so end-to-end latency tracks the critical path rather than the sum of every agent call.
  • Heavy inputs are compiled once, up front. The runner assembles the proposal context snapshot and requirement set before the workflow starts (section 5); the two loader steps pin the deal-context and solution versions at the start of the run, so generation steps issue no ad-hoc aggregation reads mid-run.
  • Step-level caching and selective retry. Per-step output caching (section 6) means a retry re-runs only the steps that hadn't already completed against the same inputs rather than regenerating every section; shouldForceRegeneration opts out for a clean re-run. In-step LLM retries are capped at one per failure tier (JSON parse, schema validation) β€” at most two calls per section per pass.
  • Bounded agent calls. Every agent call is wrapped in a stall guard with a ceiling timeout (a 5-minute default; 13 minutes for the appendices step, whose input β€” four upstream sections plus the full requirements list β€” is the largest by a wide margin) so a stalled provider stream can't wedge the run. The guard also falls back across model tiers before giving up (with a short bounded same-model retry first when a whole tier returns an empty completion, which is usually transient provider degradation), and every ceiling is deliberately held below the window that reclaims a wedged job so a genuinely stalled run fails the workflow before anything races it.
  • One run, one process. On the deployed push path each generation gets its own Cloud Run Job execution with a 1-hour task timeout, so there is no ack-deadline to outlive and no lease to extend; on the pull path the subscriber instead bounds in-flight messages and sizes its ack-deadline and lease-extension windows for long-running LLM work so a message isn't redelivered out from under an in-progress generation.
  • Refinement is cost-bounded. When enabled, the self-refining loop is capped by a max-iteration budget and stops early under the conditions in section 6 (quality bar, unrouteable findings, plateau); best-round ties resolve to the earliest round, sparing already-spent agent calls. And when a re-run's assembled content is byte-identical to a prior run, refinement is skipped entirely rather than re-judging identical content.
  • Signed-URL reuse. Download URLs are cached for their validity window, so repeated reads of the same proposal don't re-mint a signature on every request.
  • Self-healing without manual ops. A recovery sweep β€” run on startup and then every 15 minutes, coordinated across instances by an advisory lock β€” detects jobs that wedged mid-run (e.g. an LLM call that never settled, or an instance that died) and fails them via an idempotent compare-and-swap, sparing any run whose WorkflowProgress heartbeat is still fresh; never-claimed Pending jobs past their own threshold are swept the same way. On the push path the stale-InProgress threshold widens to 270 minutes, because a Cloud Run Job task-retry chain can legitimately hold a row InProgress for up to timeout Γ— (max_retries + 1) with a cold heartbeat between attempts. Combined with the pull path's shutdown revert and the out-of-band execution-failure handler, a wedged or interrupted run surfaces for retry or remediation instead of sitting stuck.
Default agent-call timeout 5 minutes 5 min
Appendices step timeout 13 minutes 13 min
Pull-path recovery-sweep window 20 minutes 20 min
Cloud Run Job task timeout 60 minutes 60 min
The timeout ladder: every per-agent ceiling sits well below the window that reclaims a wedged job, so a stalled run fails the workflow before anything races it.

Summary

End-to-end the flow looks like:

End-to-end message flow Web UI API Pub/Sub β†’ dispatcher Cloud Run Job Generate proposal user action on a deal initiateProposalGeneration (dealId Β· style? Β· instructions?) Kickoff (resolver) validate preconditions job + placeholder vN advance deal stage advisory lock per deal publish message job handle + 3 live events Push β†’ dispatcher <env>.deal-output. generate-proposal-document OIDC-gated POST Β· CAS claim start Job execution container overrides Job boots runner slim Nest context publish InProgress Prepare inputs quality gates β†’ isDraft ProposalContextSnapshot 18 WorkflowStep rows 18-step workflow section-agent fan-out refinement loop renders + uploads .docx Finalize (txn) Asset + DealOutput vN activity + evaluations enqueue evaluation job Redis pub/sub β€” dealOutputChanged (UPDATED) Live UI updates placeholder β†’ download link
From click to live update: the mutation enqueues, Pub/Sub push hands off to the dispatcher, a dedicated Cloud Run Job generates and renders, and Redis events swap the "Generating…" placeholder for the finished proposal β€” no polling, no refresh.