Skip to main content

Progressive Home Config-Driven — Troubleshooting

Quick reference for debugging the config-driven Progressive Home flow. Drag this file into chat to give the agent full context on known issues, the API call sequence, and how to capture network traces from the real FAO portal.

If you need the architecture walkthrough for a demo or onboarding, start with Config-Driven POC. This page stays focused on debugging sequences, failure modes, and recovery steps.

Current API Call Sequence (Products Step)

The Products step (ProductsHO) requires a specific multi-step sequence that mirrors the FAO portal. Getting this wrong causes bare 500s from Progressive.

Remember Our document, here, might be outdated. So make sure you match the progressive parity to our portal and not the other way around. We are essentially whitelabeling the Progressive Agent Portal.

Key Files

FileWhat It Does
progressive-step.service.tssubmitStepRaw()Orchestrates the full submit flow
progressive-step.service.tsrunProductsEligibility()PUT eligibility + PUT flow type
progressive-step.service.tssaveCurrentStateBeforeAdvance()PUT CurrentWorkflowState to persist form data
progressive-http-api.client.tsrefreshStepRelevancy()The actual PUT CurrentWorkflowState HTTP call
progressive-http-api.client.tssetPropertyQuoteFlowType()PUT PropertyQuoteFlowType?flow=Full
progressive-http-api.client.tsvalidatePropertyEligibility()PUT PropertyAddressEligibility?flow=Validate
progressive-http-api.client.tsupdatePropertyCoveragePackage()PUT UpdatePropertyCoveragePackage (package toggle)
progressive-step.service.tsalignPackageTypeWithSelection()Syncs PackageType with SelectedPackage before PUT
config-driven-step-renderer.tsxhandleRefresh()Detects SelectedPackage changes, dispatches package update
build-step-schema.tsbuildDefaultValues()Computes form defaults from HAL questions + formValues

Coverage Package Update Sequence (CoveragesHO Step)

When the user selects a different package (HomeShield / HomeShield Plus), a single API call updates all coverage fields:

Live FAO QA verification on the dedicated browser quote confirms the package change is triggered by clicking the package tiles in Package Options and that each toggle issues exactly one PUT UpdatePropertyCoveragePackage?expand=[...].

The FAO portal does not issue a GET CurrentWorkflowState before or after that package toggle. After the PUT, the SPA issues POST composite-stateless/PropertyNonSaveRatingCache requests to refresh package and bill-plan pricing variants. The response to UpdatePropertyCoveragePackage still carries the updated HAL state used to refresh package-dependent fields, updated ValidValues, and changed ShouldDisableControl flags.

Known Issues and Fixes

1. Bare 500 on POST NextWorkflowState (Products)

Symptom: POST NextWorkflowState?workflowNode=ProductsHO returns 500 with empty body:

{"CurrentPageNav":null,"ActiveViewModelHasEdits":false,"Links":[],"Extenders":{},"Embedded":{},"ValidationDetails":{},"Messages":{},"ApplicationBuildVersion":""}

Root cause: Progressive requires form data to be saved via PUT CurrentWorkflowState before advancing via POST NextWorkflowState. The FAO portal does this automatically as the user fills fields. Our backend must do it explicitly.

Fix: After eligibility + flow-type calls, do a fresh GET CurrentWorkflowState to obtain the complete post-eligibility HAL state (with all newly revealed sections), rebuild the payload from that fresh state, then call PUT CurrentWorkflowState to save before POST NextWorkflowState. Using the flow-type response directly produces a malformed payload because it has a different embedded structure than a full GET. This is handled by submitStepRaw() in progressive-step.service.ts.

Log pattern to look for:

Running PropertyAddressEligibility validation
[eligibility:PropertyAddress] PUT → 200 ✓
Setting PropertyQuoteFlowType to Full
[flowType:PropertyQuote] PUT → 200 ✓
[current:ProductsHO] GET CurrentWorkflowState → 200 ✓ ← FRESH GET after eligibility
Saving form state via PUT CurrentWorkflowState before advancing ProductsHO
[save:ProductsHO] PUT → 200 ✓ ← THIS MUST SUCCEED
[step:ProductsHO] POST NextWorkflowState → 201 ✓

If the save step is missing or fails, the POST will 500.

2. Bare 500 on POST NextWorkflowState (Any Step — e.g. AdditionalDetails)

Symptom: Same empty 500 as above, but on a non-Products step like AdditionalDetails or CoveragesHO:

[current:AdditionalDetails] GET CurrentWorkflowState → 200
[step:AdditionalDetails] POST NextWorkflowState → 500

Root cause: The same save-before-advance requirement applies to all steps, not just Products. submitStepRaw() was only calling saveCurrentStateBeforeAdvance() inside the Products eligibility block, so non-Products steps skipped the PUT save entirely.

Fix: Moved saveCurrentStateBeforeAdvance() out of the eligibility block so it runs for every step, right before postWorkflowStep(). The log pattern should now always show a save step:

[current:<step>] GET CurrentWorkflowState → 200
Saving form state via PUT CurrentWorkflowState before advancing <step>
[save:<step>] PUT → 200
[step:<step>] POST NextWorkflowState → 201

3. Missing PropertyQuoteFlowType Call

Symptom: Products step submits but the 500 happens because Progressive hasn't transitioned to the "Full" flow.

Root cause: The FAO portal calls PUT PropertyQuoteFlowType?flow=Full after PropertyAddressEligibility. Without it, Progressive's server stays in a partial state and rejects the advance.

Fix: Added setPropertyQuoteFlowType() in progressive-http-api.client.ts and wired it into runProductsEligibility().

4. Payload Rebuild After Eligibility

Symptom: Products advances but HouseholdMembers or later steps show missing data, or Progressive returns validation errors for fields the user filled.

Root cause: After PropertyAddressEligibility and PropertyQuoteFlowType, the HAL response structure changes — new sections (RCE, Structure, Interior, Features) become available. The payload for the final POST/PUT must be built from this post-eligibility state, not the initial state.

Fix: submitStepRaw() rebuilds the payload via buildNestedPayloadFromState(postEligState, workflowNode, formData) after eligibility succeeds.

4b. PropertyApologyKickout on Second Eligibility Call

Symptom: After /step-config/validate-eligibility succeeds and the ASI quote number promotes, the next /step-config/submit hits PropertyAddressEligibility?flow=Validate again and Progressive returns 400 with:

{
"Extenders": {
"propertyApologyKickout": "True",
"propertyApologyUrl": "/Slot301/Apology/ApologyPropertyUnexpectedError?..."
},
"ValidationDetails": {}
}

The session is then killed and the subsequent POST NextWorkflowState also 400s with the same kickout, even though the risk is eligible and the user did nothing wrong.

Root cause: submitStepRaw in progressive-step.service.ts unconditionally called runProductsEligibility for ProductsHO, re-running PropertyAddressEligibility?flow=Validate + PropertyQuoteFlowType?flow=Full on a session that had already been validated via the dedicated /validate-eligibility endpoint. The FAO portal does not re-validate on submit. Feeding Progressive a payload with AddressVerifiedFlag=Y / EligibilityVerifiedFlag=Y through the validation endpoint puts its server-side state into an inconsistent shape and it returns the generic ApologyPropertyUnexpectedError. On healthy QA days the second call sometimes returns 200 and the bug stays hidden; on flaky QA days it surfaces as this apology.

Fix: PropertyEligibilityStatus VO (libs/apis/carriers/progressive/src/lib/domain/value-objects/property-eligibility-status.ts) inspects the fresh HAL for AddressVerifiedFlag === "Y" AND EligibilityVerifiedFlag === "Y". submitStepRaw gates runProductsEligibility through shouldRunEligibilityCheck(...) which short-circuits when the VO reports verified. validateProductsEligibility remains the single canonical entry point for running eligibility.

Log fingerprint after the fix:

submitStepRaw: syncId=... node=ProductsHO
[current:ProductsHO] GET CurrentWorkflowState → 200
[ProductsHO] Skipping PropertyAddressEligibility re-run — HAL reports AddressVerifiedFlag=Y and EligibilityVerifiedFlag=Y
Saving form state via PUT CurrentWorkflowState before advancing ProductsHO
[save:ProductsHO] PUT → 200
[step:ProductsHO] POST NextWorkflowState → 201

Side benefit: Removes ~4s of redundant Progressive round-trips on every ProductsHO submit.

5. Progressive QA Unreachable / ENOTFOUND / Timeouts

Symptoms:

Error: getaddrinfo ENOTFOUND a-vendor.quoting.foragentsonly.com

or

Error: connect ETIMEDOUT ... a-vendor.quoting.foragentsonly.com

or sporadic 5xx responses from composite/* endpoints with no validation payload.

Root cause: Progressive's QA environment (62.qa.foragentsonly.com and a-vendor.quoting.foragentsonly.com) is regularly unstable. It can be fully down for minutes at a time, return 5xx on random requests, or drop DNS resolution transiently. This is not a VPN or local-network issue — QA is just not a reliable environment.

Fix: Retry. If it persists for more than a few minutes:

  1. Check QA status with the Progressive team or on the shared status channel.
  2. Try hitting https://62.qa.foragentsonly.com/login/ in a browser to confirm it loads at all.
  3. If QA is confirmed down, wait it out — there is no local workaround.

6. Quote Reuse / Workflow Cursor Reset

Symptom: Opening the same quote number after a prior session shows NamedInsured as the active step regardless of how far the previous session progressed.

Root cause: Progressive's QuotingGateway/RouteQuote resets the workflow cursor to the beginning when re-opening a quote. This is by design — their SPA expects to walk through all steps again.

Not a bug in our code. The config-driven flow handles this by always starting from NamedInsured and submitting through each step.

Tip: To test from a specific step without re-submitting prior steps, use step-config/go-to with the target workflowNode.

7. MortgageeRequired Warning

Symptom: Log shows:

[ProductsHO] MortgageeRequired=Y but no MortgageeName in formData — skipping

Root cause: The HAL response indicates a mortgagee is required but the user hasn't provided one yet. This is a non-fatal warning. The mortgagee flow is handled separately in handleMortgageeIfRequired().

Not an error. The submit will still attempt to proceed. If Progressive requires the mortgagee, it will return a 400 with a validation error.

7a. PointOfSale ensureMortgageeState throws "Mortgagee information is required before advancing this step" after a mortgagee was already added

Symptom: On PointOfSale, the user has added a mortgagee via /step-config/mortgagee/add (HAL MortgageeCount is "1" and Extenders.HasMortgagee is "True"), but clicking Save & Continue surfaces a 500 from /step-config/submit with:

ERROR: step-config/submit failed: Mortgagee information is required before advancing this step.
context: ProgressiveController

Root cause: The old isMortgageeRequired(halResponse) inside progressive-step.service.ts walked halResponse.Embedded looking for a numeric NumberOfMortgagees to decide whether the quote still needed a mortgagee. Progressive QA's HAL does not emit NumberOfMortgagees for PointOfSale at all — the count lives on Embedded.PointOfSale.MortgageeCount as a string ("0" before an add, "1" after). Because the old lookup always resolved to count = 0, isMortgageeRequired returned true whenever Extenders.MortgageeRequired === 'Y' (which stays 'Y' for the entire life of the quote), so ensureMortgageeState threw the "Mortgagee information is required" error even when a mortgagee was already added and visible in the UI.

Verified against real HAL captures (logs/progressive/*_current_PointOfSale_response.json) and confirmed against FAO parity via the Playwright MCP on PROGRESSIVE_POC_PLAYWRIGHT_QUOTE_NUMBER hitting a-vendor.quoting.foragentsonly.com/Slot301/api/v1/composite/CurrentWorkflowState?workflowNode=PointOfSale:

PathValueType
Embedded.PointOfSale.MortgageeCount"1" after add / "0" beforestring
Embedded.PointOfSale.ProductSpecificInformation.List[0].POSPropertyBuyViewModel.Extenders.MortgageeRequired"Y" (static)string
Embedded.PointOfSale.ProductSpecificInformation.List[0].POSPropertyBuyViewModel.Extenders.HasMortgagee"True" after add / "False" beforestring
NumberOfMortgageesabsent

Fix: isMortgageeRequired now delegates the count resolution to the existing extractMortgageeCount helper (which already handles the string/number dual-typing seen in HAL) and checks Extenders.MortgageeRequired === 'Y' separately via hasMortgageeRequiredFlag. Returns true only when the required flag is present and the extracted count is 0.

Diagnostic pattern: When in doubt about a HAL count/flag field, dump the latest logs/progressive/*_current_<Step>_response.json and grep for the exact key. Don't trust memory — Progressive exposes some counts as strings, some as numbers, some as view-model aggregates, and some (like NumberOfMortgagees) not at all.

8. Update Eligibility Button — Disabled State

Symptom: The "Update Eligibility" button is grayed out / disabled.

Root cause: The button disables when post-eligibility sections (RCE, Structure, Interior, Features, FlowType) already have visible fields. This means eligibility was already validated for the current form state.

How it works: useProductsEligibilityLayout() in products-eligibility.tsx splits sections into pre-eligibility (ProductsHO, Address, Eligibility) and post-eligibility (everything else). If any post-eligibility question has ShouldDisplay: true, the button disables.

To re-enable: This is a known limitation. If the user changes a pre-eligibility field (e.g., address), the button should ideally re-enable. Current behavior keeps it disabled once post-eligibility sections are visible.

9. Coverage Package Toggle — Fields Don't Update (500 from UpdatePropertyCoveragePackage)

Symptom: User selects a different SelectedPackage (e.g., HomeShield → HomeShield Plus) on the CoveragesHO step. The page shows "Package update failed" and dependent shield-icon fields (Personal Property, Loss Assessment, Water Backup, etc.) never update.

Server log shows:

[package:CoverageUpdate] PUT .../UpdatePropertyCoveragePackage?expand=... -> 500

Response is an empty HAL body:

{"CurrentPageNav":null,"ActiveViewModelHasEdits":false,"Links":[],"Extenders":{},"Embedded":{},"ValidationDetails":{},"Messages":{},"ApplicationBuildVersion":""}

Root cause: The frontend form includes a hidden field PackageType (in HIDDEN_CUSTOMER_FIELDS) whose value is set from formValues during initial render. When the user changes SelectedPackage to "HSP", the form still carries PackageType: "HS" because PackageType has no rendered Controller — it's populated from buildDefaultValues and never re-synced. Progressive's API rejects the request when these two fields are inconsistent.

What the FAO portal does: The portal changes package from the Package Options tiles and makes one PUT UpdatePropertyCoveragePackage?expand=[...] call per toggle. The response contains the fully updated HAL state with recalculated LastAnswer values for all dependent fields. The portal does not make a GET CurrentWorkflowState around this action, but it does issue follow-on PropertyNonSaveRatingCache POSTs for non-save rating updates. The portal's Angular app keeps PackageType and SelectedPackage in sync.

Fix (two layers, defense in depth):

  1. Backend (progressive-step.service.ts): alignPackageTypeWithSelection() ensures PackageType always matches SelectedPackage before the PUT to Progressive. This is the authoritative fix.

  2. Frontend (config-driven-step-renderer.tsx): In handleRefresh, when SelectedPackage changes, the payload is constructed with { ...sanitized, PackageType: value } so the two fields are consistent before leaving the browser.

After the fix: Progressive returns 200 with updated coverage values. The response flows through buildStepConfigextractAllQuestions (questions with new LastAnswer) → extractFormValues (new values) → frontend buildDefaultValuesform.reset(defaults) → all fields update.

Fields that change per package (HomeShield vs HomeShield Plus):

FieldHS (HomeShield)HSP (HomeShield Plus)
Personal Property50% of Cov A70% of Cov A
Increased Sublimit Jewelry$3,000 ($1,500/item)$5,000 ($3,000/item)
Increased Replacement Cost on Dwelling25% of Cov A50% of Cov A
Loss Assessment$2,500$5,000
Ordinance or Laweditable dropdown (blank default)locked text "25% of Cov A"
Water Backup Coverage$5,000$10,000
Special Personal Property (checkbox)unchecked, enabledchecked, disabled

Debugging tip: If the 500 recurs, dump formData in the server log and check that SelectedPackage and PackageType match. Any other hidden field mismatch can cause similar failures.

9a. CoveragesHO defaults (PersonalLiability $500K + HiddenSeepageOrLeakage $20K + LimitedFoundationAndSlabBuyback Y + PersonalPropertyReplacementCost Y) on first CoveragesHO landing

Symptom: The Liability accordion's PRD copy reads "This is defaulted to $500,000 but can be changed to fit your needs.", but the dropdown initially shows a different value (e.g. $100,000) on a fresh DA-bridged quote. Or: the Hidden Seepage or Leakage row in the Add-on Coverages accordion mounts blank instead of showing $20,000 per Figma. Or: the Limited Foundation & Slab Buyback checkbox in the optional cluster mounts unchecked instead of pre-checked. Or: the Personal Property Replacement Cost checkbox in the optional cluster mounts unchecked instead of pre-checked.

Source of truth: ProgressiveCoveragesHoDefaultsStore is marked with { PersonalLiability: '500000', HiddenSeepageOrLeakage: '20000', LimitedFoundationAndSlabBuyback: 'Y', PersonalPropertyReplacementCost: 'Y' } at the end of ProgressiveQuoteDuplicatorService.duplicateFromCrn, then consumed by ProgressiveStepService.applyPendingCoveragesHoDefaults on the first CoveragesHO landing. The method PUTs the entire seed via a single refreshStepRelevancy round-trip, GETs fresh HAL, and clears the store so HAL LastAnswer is authoritative on every subsequent visit.

Log fingerprint (success):

Marked CoveragesHO defaults pending for syncId=<8>: PersonalLiability='500000', HiddenSeepageOrLeakage='20000', LimitedFoundationAndSlabBuyback='Y', PersonalPropertyReplacementCost='Y'
... user walks ProductsHO -> People -> AdditionalDetails -> CoveragesHO ...
Applying pending CoveragesHO defaults for syncId=<8> (4 fields: PersonalLiability, HiddenSeepageOrLeakage, LimitedFoundationAndSlabBuyback, PersonalPropertyReplacementCost)
[refreshRelevancy:CoveragesHO] PUT -> 200
[current:CoveragesHO] GET CurrentWorkflowState -> 200

If the seed never fires:

  1. Check that ProgressiveQuoteDuplicatorService.duplicateFromCrn ran for this quote — only the duplicator path marks the store. Direct quote URLs that bypass duplication won't have a pending entry.
  2. Check the gateway hasn't restarted between duplication and CoveragesHO landing — the store is in-memory only (matches ProgressivePeoplePrefillStore).
  3. If the PUT fails, the apply is non-fatal — the original HAL is returned and the entry stays in the store for a future retry. Look for Pending CoveragesHO defaults apply failed (non-fatal): ... in the gateway logs.

Full design rationale and trade-offs live in progressive-ux-presentation-only-config.md → "CoveragesHO defaults — server-seeded at duplication".

9b. Stale Liability / Deductibles / Add-ons answers carry across a coverage-package switch (POD9-549)

Symptom: On CoveragesHO, the customer sets a value under one package (e.g. HomeShield → Personal Liability $500,000), clicks Next so Liability shows the green Complete badge, then switches to a different package (e.g. Custom). The new plan keeps the prior plan's edited values and the Complete badges instead of resetting to the new plan's HAL defaults. The customer cannot tell whether the UI reflects the active plan's authoritative configuration. Confirmed in spike POD9-509.

Root cause: handlePackageSelect in coverages-step.tsx only called clearConfirmedAccordion('propertyCoverages'). Three pieces of per-quote state leaked across the switch:

  1. The confirmedLiability / confirmedDeductibles / confirmedAddOns snapshots in goosehead-progressive-home-coverages-ui sessionStorage kept spreading over the fresh HAL defaults inside the defaults useMemo.
  2. committedAccordions kept true for the non–Property Coverages sections, so their Complete badges persisted.
  3. Local accordionStatus React state kept those sections complete / open, so the customer was never re-invited to review them under the new plan.

Fix: handlePackageSelect now treats a plan switch as the full reset gesture — before dispatching updateCoveragePackage it clears all four confirmed snapshots (clearConfirmedAccordion for propertyCoverages / liability / deductibles / addOns), calls resetCommittedAccordions(), and sets local accordionStatus back to INITIAL_ACCORDION_STATUS. The existing prevDefaultsRef effect then re-runs form.reset(defaults) against the carrier-returned HAL defaults and disarms any armed per-accordion error banner; because the snapshots are gone, the new plan's values render cleanly and every Complete badge clears. The package-locked payload behavior is unchanged — PersonalProperty / LossOfUse are still stripped so HAL recomputes them, and OtherStructures is still forced to '10'.

Out of scope (per ticket): no dedicated "Reset to plan defaults" button (selecting a different package IS the reset), and no Progressive config / CDM changes.

Full design rationale lives in progressive-ux-presentation-only-config.md → "Package update flow".

10. GoToWorkflowState Returns 404

Symptom:

[goto:NamedInsured] POST .../GoToWorkflowState?workflowNode=NamedInsured -> 404

Root cause: The FAO portal does not navigate the sidebar with workflowNode=<target> and an empty body. It posts the current step view-model and sends both the source and target step identifiers:

POST /api/v1/composite/GoToWorkflowState
?workflowNode=<currentStep>
&progressBarItemId=<targetStep>
&expand=[{"descriptor":"view-model","expand":[]}]

The request body is the current step payload, not {}.

Verified FAO examples:

  • AdditionalDetails -> Products: workflowNode=AdditionalDetails&progressBarItemId=Products
  • Products -> AdditionalDetails: workflowNode=ProductsHO&progressBarItemId=AdditionalDetails

Fix: Before calling GoToWorkflowState, fetch the live HAL state, detect the current embedded step, build the nested payload from that step, then post the portal-shaped request. If Progressive still rejects the goto, fall back to GET CurrentWorkflowState for the target node.

11a. Save & Continue silently does nothing (Mortgage / PointOfSale)

Symptom: On the Mortgage step, the user has added a lender, entered SSN, and checked the credit-consent box. Clicking Save & Continue: Final Sale does nothing — no network request to /step-config/submit, no validation error surfaced in the UI, no console warning.

Root cause: The shell's footer button calls form.requestSubmit() on the form with id config-driven-form, which routes through form.handleSubmit(handleFormSubmit). Zod validates the entire form state before handleFormSubmit runs. Two classes of flat Property keys were required-but-empty in the mortgage step's zod schema and caused the resolver to short-circuit silently:

  1. Server-injected hidden fields (AutoInsurer, PropertyPolicy, AtvOnPremise, PropertyClueSelectIndicator). These are never rendered — the gateway injects fixed values server-side before advancing. Their flat form value is always empty.
  2. SocialSecurityNumber. The SSN is rendered via driver-scoped RHF keys (driver_0__SocialSecurityNumber), and the mortgage step owns SSN validation explicitly via form.trigger over those keys. The flat SocialSecurityNumber key is never populated.

Each of these fields was in the HAL response with Required: true, so buildStepSchema(allQuestions) emitted a .min(1, 'Required') check for them. When the form state had SocialSecurityNumber = '' (because the SSN lives under the driver-indexed key), zod failed validation, RHF set formState.errors.SocialSecurityNumber, and handleFormSubmit was never called. Because nothing renders that flat key, no error was ever shown to the user — the button just appeared inert.

Fix: ProgressiveMortgageStep now passes MORTGAGE_SCHEMA_EXCLUDED_PROPERTIES (= MORTGAGE_HIDDEN_PROPERTIES{ SocialSecurityNumber }) as the second argument to buildStepSchema. This is the same pattern used by coverages-step.tsx and additional-details-step.tsx. MORTGAGE_HIDDEN_PROPERTIES continues to own payload filtering semantics; the new schema-exclusion set layers SSN on top for zod-only purposes.

Key files:

  • apps/fastlane-portal/.../step-overrides/mortgage/field-groupings.ts — owns MORTGAGE_SCHEMA_EXCLUDED_PROPERTIES
  • apps/fastlane-portal/.../step-overrides/mortgage/mortgage-step.tsx — calls buildStepSchema(allQuestions, MORTGAGE_SCHEMA_EXCLUDED_PROPERTIES)
  • apps/fastlane-portal/.../renderer/build-step-schema.ts — accepts extraHiddenProperties?: ReadonlySet<string>

Diagnostic pattern: If a config-driven step ever exhibits the "button does nothing" symptom, check that:

  • The footer submits the form (form.requestSubmit() on config-driven-form).
  • The step's form.handleSubmit(...) is wired to the <form> onSubmit.
  • The step's schema excludes every flat Property name that is (a) not rendered or (b) rebound to a non-flat RHF key. Related docs: household-members-step.tsx ("Save & Continue does nothing trap") and build-step-schema.ts.

11b. SaveRaceCondition on POST NextWorkflowState

Symptom: POST NextWorkflowState returns 400 with SaveRaceCondition: "True" in Extenders:

{"Extenders":{"SaveRaceCondition":"True","ServerTransactionTime":"507"},...}

Server log:

[step:NamedInsured] SaveRaceCondition detected
[step:NamedInsured] Error: Progressive session race condition at step:NamedInsured: SaveRaceCondition

Root cause: Our backend fires POST NextWorkflowState immediately after the PUT CurrentWorkflowState (save) returns 200. However, Progressive's server hasn't finished committing the save internally — ServerTransactionTime reveals the server-side transaction took longer than the HTTP round-trip. The FAO portal's Angular SPA naturally has a rendering delay between save and advance; our backend has none.

Fix: submitStepRaw() now calls advanceWithRaceRetry() instead of postWorkflowStep() directly. On SaveRaceCondition, it waits with linear backoff (600ms × attempt), then checks if Progressive already advanced the workflow (the 400 can mask a successful transition). If the workflow moved past the submitted node, it returns the fresh state as success. Otherwise it retries the POST (up to 3 attempts).

Log pattern (already-advanced recovery):

[step:NamedInsured] POST NextWorkflowState → 400 (SaveRaceCondition)
[NamedInsured] SaveRaceCondition — retrying in 600ms (attempt 2/3)
[NamedInsured] Already advanced to .../ProductsHO — treating SaveRaceCondition as success

Log pattern (retry POST):

[step:NamedInsured] POST NextWorkflowState → 400 (SaveRaceCondition)
[NamedInsured] SaveRaceCondition — retrying in 600ms (attempt 2/3)
[step:NamedInsured] POST NextWorkflowState → 201 ✓

If retries are exhausted: The error propagates as before. Check whether something else is concurrently writing to the same quote session (e.g., a duplicate browser tab, or the frontend firing two submits).

11c. Save & Continue silently does nothing on the About You page (NamedInsured)

Symptom: On the "About You" page (NamedInsured), the user has finished both the Address and Mortgage & Ownership accordions (both showing "Complete"), but clicking Save & Continue: Property Details does nothing — no network request to /step-config/submit, no validation error in the UI, no console warning. The button appears inert.

Root cause: Same shape as issue 11a but on a different step. The new About You override (named-insured-step.tsx) only renders the Address and Mortgage accordions. Per UX, the personal-info HAL questions (AgentCode, AsiAgentCode, FirstName, MiddleInitial, LastName, Suffix, DateOfBirth, Gender, MaritalStatus, PrimaryEmailAddress, PhoneType, PhoneNumber, DisclosureProvided) are deliberately not rendered — their values flow through buildDefaultValues(...) from HAL LastAnswer into the submitted payload via useForm({ shouldUnregister: false }).

When Progressive returns one of these as Required: true with an empty LastAnswer (e.g. a quote duplicated from a DA flow that never collected Gender), the previous buildStepSchema(allQuestions) invocation kept the field in zod with .min(1, 'Please select an option'). The default value resolved to '', zod failed, react-hook-form set formState.errors.Gender, and handleFormSubmit was never called. Because nothing renders Gender, handleFormInvalid had no error to surface — the button looked broken.

Verified against real HAL captures (logs/progressive/*_refresh_NamedInsured_response.json):

Property                  Required  ShouldDisplay  LastAnswer
FirstName true true 'Alice'
LastName true true 'Wonderland-B'
DateOfBirth true true '19900101'
Gender true true '' ← silent zod fail

Fix: NAMED_INSURED_HIDDEN_PROPERTIES in apps/fastlane-portal/.../step-overrides/named-insured/field-groupings.ts lists every personal-info HAL property the page never renders. named-insured-step.tsx seeds the dynamic schema's extraHiddenProperties from that set so zod skips .min(1) checks on hidden fields. Their values still round-trip via buildDefaultValues (.passthrough() in the schema) and reach Progressive in the submitted payload — exactly the same byte-for-byte behavior as before for sessions that have non-empty LastAnswer values. If Progressive truly needs the missing value (e.g. Gender), it will surface a server-side validation error on submit instead of the front-end silently doing nothing.

Diagnostic pattern when a Save & Continue still silently fails: dump the latest logs/progressive/*_refresh_<Step>_response.json, find every question with Required: true, ShouldDisplay: true, Disabled: false, and an empty LastAnswer, and confirm whether the step actually renders that property. If not, add it to that step's hidden-properties set.

11d. Submit Payment shows MortgageeLoanId: Required on Checkout (FinalSaleHO)

Symptom: On the config-driven Checkout step, the user sees a red validation line such as MortgageeLoanId: Required above Submit Payment. The Figma checkout only shows read-only mortgagee company and address (from mortgagees); there is no loan-ID input on that page.

Root cause: Same §11a / §11c pattern. FinalSaleHO HAL can flatten MortgageeLoanId (and sometimes MortgageeLoanNumber) as Questions.List entries with Required: true. They are collected on the Mortgage step (or lifted from the singular Mortgagee object into stepConfig.mortgagees[].loanNumber via extractMortgagees), but the checkout override does not render those controls. If the flat LastAnswer is empty in the step config while zod still has a .min(1) rule, react-hook-form blocks submit and surfaces the root error.

Fix: CHECKOUT_HIDDEN_PROPERTIES in step-overrides/checkout/field-groupings.ts includes MortgageeLoanId and MortgageeLoanNumber so buildStepSchema(..., CHECKOUT_SCHEMA_EXCLUDED_PROPERTIES) does not validate them. checkout-step.tsx overlays both keys on submit from sanitizedPayload when present, otherwise from stepConfig.mortgagees[0].loanNumber, matching progressive-rc1-step-mapper / progressive-step.service expectations for the sell payload.

HAL Knockouts (ineligibility edits)

Progressive rejects some submissions with HTTP 400 + a HAL field edit whose description indicates the risk is ineligible (e.g. AnimalType: Risk is ineligible for this program. on AdditionalDetails). The config-driven flow detects these knockouts once, in the gateway, and routes the user to the dedicated knockout page (PROGRESSIVE_HOME_ROUTES.KNOCKOUT). There is no dismissible modal — a knockout is a terminal state for the quote.

Contract

  • Source of truth: ProgressiveKnockoutDetector (libs/apis/carriers/progressive/src/lib/domain/value-objects/progressive-knockout-detector.ts) owns PROGRESSIVE_KNOCKOUT_PATTERNS and fromFieldEdits(...). Add new patterns here, not at call sites.
  • Backend → Frontend signal: the gateway returns { advanced: false, stepConfig } where stepConfig.pendingConfirmation = { type: 'KNOCKOUT', workflowNode, reasons, propertyNames, source }. No 5xx; a knockout is a valid business outcome.
  • Frontend: useHalKnockoutNavigation (apps/fastlane-portal/.../config-driven/shell/use-hal-knockout-navigation.ts) inspects every stepConfig fed into setStepConfig and, on KNOCKOUT, calls navigateToKnockout(...).
  • Fallback: the same hook also runs extractKnockouts(...) over HAL 200 responses with soft-edit knockout descriptions, so the old pattern-match path still routes to the page without requiring a backend re-roll.

Call sites in progressive-step.service.ts

The helper is invoked from every entry point that can surface a field-validation 400:

MethodCatch target
submitStepRawPOST NextWorkflowState advance
validateProductsEligibilityPUT PropertyAddressEligibility / PropertyQuoteFlowType
refreshStepRelevancyPUT CurrentWorkflowState
updateCoveragePackagePUT UpdatePropertyCoveragePackage

All of them call tryBuildKnockoutStepConfig(error, workflowNode, session) before re-throwing.

Log fingerprint

[step:AdditionalDetails] Progressive returned 400 with 1 field edit(s): AnimalType:Risk is ineligible for this program.
[AdditionalDetails] HAL knockout detected — returning KNOCKOUT confirmation (properties=AnimalType reasons=Risk is ineligible for this program.)

The frontend then navigates to /carriers/progressive/home/knockout with a KnockoutPageState whose message is the first reason and source is progressive-home-<slug>-hal-knockout.

Extending

  • New knockout phrases from Progressive: append a regex to PROGRESSIVE_KNOCKOUT_PATTERNS and add a matching case to progressive-knockout-detector.spec.ts.
  • Finer-grained messaging: ProgressiveKnockoutConfirmation.propertyNames carries the HAL properties that failed, so knockout copy can be customized per field without changing the contract.

PointOfSale Alert / Error Surface Map

The PointOfSale HAL response always emits the full alert/error surface, even when nothing is wrong. Every container is present with empty collections and "false" / "N" / "" toggles. This makes detection deterministic — you can read the same paths on every response and only react when they flip on.

The FAO portal renders three distinct disqualification surfaces from these fields:

  1. A hard kickout modal ("we are unable to offer your customer a policy at this time")
  2. A Point of Sale Alerts warning panel ("Claim(s) reported may disqualify this risk...")
  3. A Property Information Returned with CLUE claims table

All three are detected in the gateway and routed to PROGRESSIVE_HOME_ROUTES.KNOCKOUT via the same ProgressiveKnockoutConfirmation contract used by the existing HAL knockouts. DTC copy IS rewritten — Progressive's verbatim message, phone numbers (1-866-274-8765), View Risk Eligibility Letter link, vendor names (ChoicePoint, LexisNexis), and Underwriting references NEVER reach the customer. Implementation tracked under POD9-408; see §Detection Contract below for the file map.

1. Hard Kickout Modal — Root Extenders

The only surface that is NOT always emitted. Lives at the root of the response (not inside Embedded.PointOfSale). Currently consumed by progressive-http-api.client.ts#detectSessionErrorProgressiveSessionKickoutError. See §4b for the Products-step variant.

HAL PathInactiveActive
Extenders.PropertyApologyKickoutabsent"True"
Extenders.PropertyApologyUrlabsent/Slot301/Apology/ApologyPropertyUnexpectedError?edits=...

2. Point of Sale Alerts Panel — ProductSpecificInformation Messages

All four message buckets are always emitted with Count: 0 when empty. The disqualify warning lands as a new PolicyMessages.List[] entry with MessageType: "warning" and a Code (CLUE codes are the canonical trigger). On a clean Texas HO quote, PolicyMessages.List already carries 5 informational entries (NOHIT001, NOCOV001, EFFDT001, CLMFD001, plus an unkeyed claim-history disclosure) — those are not knockouts.

HAL PathEmpty shapePopulated trigger
Embedded.PointOfSale.ProductSpecificInformation.List[0].PolicyMessages{ List: [...info msgs...], Count: N }+1 entry { MessageType: "warning", Code: "...", Text: "..." }
Embedded.PointOfSale.ProductSpecificInformation.List[0].PosMessages{ List: [], Count: 0 }populated List[]
Embedded.PointOfSale.ProductSpecificInformation.List[0].UDEMessages{ List: [], Count: 0 }populated List[]
Embedded.PointOfSale.ProductSpecificInformation.List[0].SPPPolicyMessages{ List: [], Count: 0 }populated List[]
Embedded.PointOfSale.Messages{}object with edits
Embedded.PointOfSale.ValidationDetails{}object with details

Each *Messages.List[*] entry shape:

{
"Name": null,
"Text": "Claim(s) reported may disqualify this risk...",
"LinkText": null,
"AqEventId": null,
"MessageType": "warning|information|error",
"Code": "CLUE...",
"CurrentPageNav": null,
"Links": [],
"Extenders": {},
"Embedded": {},
"ValidationDetails": {},
"Messages": {},
"ApplicationBuildVersion": "6.0.0.1561"
}

Per-product ineligibility flags on POSPropertyBuyViewModel.Extenders (always strings):

PathInactivePurpose
...POSPropertyBuyViewModel.Extenders.ShouldDisplayIneligibilityMsg"false"Generic ineligibility banner
...POSPropertyBuyViewModel.Extenders.ShouldDisplayIneligibilityHomeInspMsg"false"Inspection-driven ineligibility
...POSPropertyBuyViewModel.Extenders.ShouldDisplayPropertyClueClaimsTable"N"Toggle for the CLUE claims table
...POSPropertyBuyViewModel.Extenders.ShouldDisplaySPPOccupancyMessage"N"SPP occupancy warning

Page-level alert toggles on Embedded.PointOfSale.Extenders (always strings, mostly Pascal-case "False" or "N"):

PathInactiveTriggers
...PointOfSale.Extenders.ShouldShowKickoutCreditFailureModal"N"Credit kickout modal
...PointOfSale.Extenders.ShouldDisplayHealthInsurancePOSAlert"False"Health insurance POS alert
...PointOfSale.Extenders.ShouldShowSalePendingSignedFormsAlert"false"Sale-pending signed forms
...PointOfSale.Extenders.ShouldDisplayAmountPaidTodayAlert"false"Amount-paid-today alert
...PointOfSale.Extenders.ShouldShowUDERUMessage"False"UDE rule update
...PointOfSale.Extenders.ShouldShowUdeMandateHistoryMessage"False"UDE mandate history
...PointOfSale.Extenders.ShouldShowSpecificMPDText"False"Multi-product discount text
...PointOfSale.Extenders.ShouldShowMvrYrsLicMismatchMsg"False"MVR/license mismatch
...PointOfSale.Extenders.PropertyUnexpectedEdits""Free-form unexpected-edit string
...PointOfSale.Extenders.HasEdits"False"Any edit on the page
...PointOfSale.Extenders.HomeReconstructionNotice""Reconstruction warning text

3. CLUE Claims Table — Embedded.PointOfSale

The "No activity found" empty state is just Count: 0 on these collections. They are always emitted.

HAL PathEmpty shapePopulated shape
Embedded.PointOfSale.PropertyClueClaims{ List: [], Count: 0, Links: [], Extenders: {}, Embedded: {}, ValidationDetails: {}, Messages: {} }List[*] with DOL/Occurrence/Peril/Amount/Status/CAT
Embedded.PointOfSale.PropertyReportedClaimsViewModelsame empty shapecustomer-disclosed prior claims
Embedded.PointOfSale.PropertyClueOrderedfalsetrue
Embedded.PointOfSale.PropertyClueSelectIndicator"Y""Y"
Embedded.PointOfSale.PropertyClueReorderfalsetrue / false
Embedded.PointOfSale.ClueExpirationDatenullISO date
Embedded.PointOfSale.ClueOrderDatenullISO date
Embedded.PointOfSale.ClueReferenceNumbernullstring
Embedded.PointOfSale.ClueVendorNamenull"LexisNexis" etc.

Embedded.PointOfSale.Collections enumerates these collection keys explicitly:

["productSpecificInformation","drivers","propertyClueClaims","itemizedScheduledPersonalProperties","propertyReportedClaimsViewModel"]

The letter itself is a print document only emitted on the PrintHO step (post-bind). It is not in PointOfSale HAL at all — FAO renders the link as a static modal action when an ineligibility flag is set, then lazy-fetches via PrintWorkflowState:

{
"ID": "CHOICEPOINTDENIAL",
"PolicyID": "TST967506",
"FormCode": "CHOICEPOINTDENIAL",
"Description": "Risk Eligibility Letter",
"IsSignatureForm": false,
"IsPolicyForm": false
}

Path: Embedded.PrintHO.PropertyDocuments.List[*] filtered by FormCode === 'CHOICEPOINTDENIAL'. We do not surface this link to the DTC customer — knockout copy is rewritten Goosehead-branded.

5. Per-Question Edits — Always Present

Every question carries its own validation slot. Already consumed by ProgressiveQuestionExtractorProgressiveKnockoutDetector.

Per-question pathInactive shape
...Questions.List[*].HasEditfalse
...Questions.List[*].Edits[]
...Questions.List[*].ForceEditToBeDisplayedfalse
...Questions.List[*].ValidationId""
...Questions.List[*].ValidationDetails{}
...Questions.List[*].Messages{}

Detection Contract

Detection extends the ProgressiveKnockoutDetector pattern (which is field-edit driven and only fires on 400-response ProgressiveFieldValidationError) with a new ProgressivePointOfSaleAlertDetector value object that runs against any 200-response HAL whose workflowNode === 'PointOfSale'. The legacy detector keeps owning AdditionalDetails / FinalSaleHO knockouts; POD9-408 is scoped to PointOfSale only.

File map

ConcernFile
HAL detection + step-config factorylibs/apis/carriers/progressive/src/lib/domain/value-objects/progressive-point-of-sale-alert-detector.ts
Goosehead-branded copy table (single source of truth)libs/apis/carriers/progressive/src/lib/domain/value-objects/progressive-knockout-copy.ts
Knockout reason categories + precedencelibs/apis/carriers/progressive/src/lib/domain/value-objects/progressive-knockout-reason-category.ts
Curated knockout-grade PolicyMessages.Code valueslibs/apis/carriers/progressive/src/lib/domain/value-objects/progressive-pos-knockout-codes.ts
Real + synthetic HAL fixtureslibs/apis/carriers/progressive/src/lib/domain/value-objects/__fixtures__/point-of-sale-{clean,apology-kickout,credit-failure,clue,ineligible,ineligible-inspection,warning-code}.json
Service-side wiring (200 path)libs/apis/carriers/progressive/src/lib/application/services/progressive-step.service.tstryBuildPointOfSaleKnockoutStepConfig injected at top of buildPointOfSaleStepConfig
Controller-side wiring (400 apology — Option A)apps/apis/fastlane-api-gateway/src/app/controllers/progressive.controller.tsbuildSessionKickoutStepConfig branches on workflowNode === 'PointOfSale'

Surface → category map

SurfaceDetector InputKnockoutReasonCategorysource analytics tag
Hard apology kickout (200 path)Extenders.PropertyApologyKickout === "True"POS_APOLOGY_KICKOUTprogressive-home-point-of-sale-apology-kickout
Hard apology kickout (400 path)ProgressiveSessionKickoutError thrown by progressive-http-api.client.ts#detectSessionError, intercepted in controllerPOS_APOLOGY_KICKOUT (same copy entry)same
Credit-based eligibility kickoutEmbedded.PointOfSale.Extenders.ShouldShowKickoutCreditFailureModal === "Y"POS_CREDIT_FAILUREprogressive-home-point-of-sale-credit-failure
CLUE claims disqualify the riskEmbedded.PointOfSale.PropertyClueClaims.Count > 0 AND …POSPropertyBuyViewModel.Extenders.ShouldDisplayPropertyClueClaimsTable === "Y"POS_CLUE_CLAIMSprogressive-home-point-of-sale-clue-claims
Inspection-driven ineligibility…POSPropertyBuyViewModel.Extenders.ShouldDisplayIneligibilityHomeInspMsg === "true"POS_INELIGIBLE_INSPECTIONprogressive-home-point-of-sale-ineligible-inspection
Generic ineligibility flag…POSPropertyBuyViewModel.Extenders.ShouldDisplayIneligibilityMsg === "true"POS_INELIGIBLEprogressive-home-point-of-sale-ineligible
Warning-coded PolicyMessages entryany …PolicyMessages.List[*].MessageType === "warning" AND Code ∈ POS_KNOCKOUT_CODESPOS_WARNING_CODEprogressive-home-point-of-sale-warning-code

Precedence

When more than one surface is active on the same HAL response, the most-specific tier wins. Order is the single source of truth in KNOCKOUT_REASON_CATEGORY_PRECEDENCE:

POS_APOLOGY_KICKOUT  →  POS_CREDIT_FAILURE  →  POS_CLUE_CLAIMS  →  POS_INELIGIBLE_INSPECTION  →  POS_INELIGIBLE  →  POS_WARNING_CODE

The detector emits the existing ProgressiveKnockoutConfirmation shape (now with the displayMessage field populated from the copy table), so useHalKnockoutNavigation routes to the same knockout page with no frontend changes.

Coexistence note (Option A)

progressive-http-api.client.ts#detectSessionError already throws ProgressiveSessionKickoutError whenever Extenders.PropertyApologyKickout === "True" regardless of HTTP status. POD9-408 chose to leave that throw in place and remap it at the controller level (buildSessionKickoutStepConfig PointOfSale branch) — the new detector still recognizes the apology surface as a defense-in-depth fallback for any 200-response path that bypasses detectSessionError. Both paths converge on the same POS_APOLOGY_KICKOUT copy entry.

Copy Rewriting Rules (DTC)

All Progressive-authored text is replaced before reaching the DTC customer. The map is enforced by a parameterized spec sweep in progressive-point-of-sale-alert-detector.spec.ts (PROGRESSIVE_KNOCKOUT_COPY — DTC safety sweep) and by a controller-level forbidden-token assertion in progressive.controller.spec.ts (buildSessionKickoutStepConfig (POD9-408 Option A …)):

Forbidden in DTC copyWhyGoosehead-branded replacement
Phone 1-866-274-8765Progressive agent line"Call Goosehead at (833) 779-4090"
View Risk Eligibility Letter linkProgressive adverse-action UI element with FCRA implicationsOmit; surface generic "We'll mail you a letter explaining the decision."
Underwriting for further reviewInternal Progressive workflowOmit
consult with your customerAgent-portal verbiageRewrite to first-person customer voice
ChoicePoint / LexisNexis brand mentionsConsumer reporting agency vendor names"consumer reporting agency"

Copy is centralized in progressive-knockout-copy.ts and keyed by KnockoutReasonCategory. PMs can iterate copy text by editing entries in that single file — adding a new category requires a corresponding entry plus a unit test (the exposes a copy entry for every KnockoutReasonCategory spec catches missing entries at CI time).

NamedInsured Parity with FAO

Captured live from the FAO portal (https://a-vendor.quoting.foragentsonly.com/.../Quote/Index) for quote Q84078154. The config-driven renderer should expose the same interactive fields in the same order. Sub-section headers on the FAO portal are UI grouping only — our single NamedInsured card is acceptable as long as the field ordering and behavior match.

Field Matrix

FAO Sub-sectionProgressive PropertyFAO ControlConfig-Driven Handling
PolicyAgentCodeDropdown (PGR Agent Code)Rendered via ConfigDrivenField (dropdown, 6-col span)
PolicyAsiAgentCodeText (ASI Agent Code)Rendered via ConfigDrivenField (text, 6-col span)
Principal Named InsuredFirstNameTextRendered (4-col)
Principal Named InsuredMiddleInitialTextRendered (2-col, between First/Last to match FAO)
Principal Named InsuredLastNameTextRendered (4-col)
Principal Named InsuredSuffixDropdownRendered (2-col)
Principal Named InsuredDateOfBirthDate MM/DD/YYYYRendered (HTML date, ISO under the hood)
Principal Named InsuredGenderDropdown (Female/Male)Rendered
Principal Named InsuredMaritalStatus (when HAL reveals it)DropdownRendered
Contact InformationPrimaryEmailAddressText + help tooltipRendered (no tooltip — acceptable)
Contact InformationPhoneType / PhoneNumberDropdown + masked text + Add/ClearRendered via PhoneNumbers HAL section
Current Mailing AddressHasInternationalAddressCheckbox (Y/N)Rendered as Yes/No radio (behavioral parity)
Current Mailing AddressMailingAddressTextRendered (9-col)
Current Mailing AddressApartmentUnitTextRendered (3-col)
Current Mailing AddressCity / State / ZipCodeText/Dropdown/TextRendered
Current Mailing AddressMailingZipTypeCheckbox (P.O. Box or a Military AddressP vs O)Rendered as dropdown (values O/P — not Y/N)
Current Mailing AddressRecentlyMovedDropdown (No/Yes)Rendered as Yes/No radio
DisclosureDisclosureProvidedDropdown (Yes/No)Rendered as Yes/No radio

Field Ordering

FIELD_DISPLAY_ORDER in config-driven-step-renderer.tsx mirrors the FAO top-to-bottom order. Any new NamedInsured field from Progressive should be added to this list to preserve deterministic placement. Fields not in the list fall to the bottom in the order Progressive returns them.

Fields Previously Hidden (now shown for parity)

Historically HIDDEN_CUSTOMER_FIELDS in build-step-schema.ts hid the following properties and FORCED_DEFAULTS overrode their values every render. This broke parity: the agent could not change the PGR/ASI agent code, toggle international address, toggle P.O. Box, or confirm disclosure.

FieldPrior BehaviorCurrent Behavior
AgentCodeHiddenVisible dropdown
AsiAgentCodeHiddenVisible text
DisclosureProvidedHidden, always forced to YVisible Yes/No; Y is a fallback only when HAL LastAnswer is empty
HasInternationalAddressHidden, always forced to NVisible Yes/No; N fallback only when HAL is empty
MailingZipTypeHidden, always forced to OVisible dropdown; O fallback only when HAL is empty

The new FALLBACK_DEFAULTS map in build-step-schema.ts is applied only when the Progressive HAL LastAnswer is empty. Any value Progressive has already stored (including a user choosing N for disclosure or P for a P.O. Box) is respected.

Behaviors Intentionally Not Replicated

  • Per-section card headers ("Policy", "Principal Named Insured", etc.). Our renderer uses a single NamedInsured card because the HAL section leaf is flat. Visual grouping may be revisited, but behavior parity is already achieved.
  • Help tooltips next to the Email / Phone / Mailing Address labels. FAO renders contextual help buttons; we rely on field-level SubLabel from HAL when present. (Out of scope for the shared Helpful Hints rail — see "Helpful hints rail does not open" below for the rail that covers Property Details, Additional Details, and Coverages.)
  • Print Disclosure button. FAO exposes a print action for the disclosure paragraph. Not critical for quoting; can be added when needed.
  • Multi-phone "Add Phone Number" button. Progressive's HAL exposes PhoneNumbers as a list; add/remove parity lives in the HAL section rendering, not in NamedInsured-specific code.

CRITICAL: Quote Isolation — Two Browsers, Two Quote Contexts

The config-driven flow and the Playwright MCP parity browser MUST operate on separate quotes. Progressive's API is stateful — every GET/PUT/POST mutates the workflow cursor, field values, and eligibility state on Progressive's server. If two consumers (our backend/cursor browser session and a Playwright MCP parity session) touch the same quote concurrently or even sequentially, the state drifts and subsequent API calls fail with bare 500s, stale field values, or SaveRaceConditions.

Browser Roles

BrowserPurposeQuote It Opens
cursor-ide-browser (the Cursor IDE browser)Exercising code changes end-to-end against the currently active quote. That active quote is whatever DA/GAQ just duplicated into Progressive under our agent credentials (see entry-from-da.md). This is the quote the user is testing right now.The live active quote from the DA landing URL (?q=Q...) or the duplicate Q-number Progressive minted after NamedInsured.
user-playwright-mcp (the Playwright MCP browser)Parity checks only. Dump FAO behavior, capture network requests, observe the Progressive SPA's real API sequence, and bring that parity into our code. The Progressive agent portal is the source of truth — our docs and code may be stale.PROGRESSIVE_POC_PLAYWRIGHT_QUOTE_NUMBER from .env. Nothing else.

PROGRESSIVE_POC_QUOTE_NUMBER is deprecated. It previously seeded a default quote for the config-driven flow before DA/GAQ landing existed. It is no longer consulted by the gateway and should not appear in new docs, agent prompts, or workflows. If you see it anywhere, treat it as dead code and remove it.

Why This Matters

Progressive quotes are server-side stateful objects. When our backend GETs the current workflow state, it receives a HAL document whose embedded sections, field values, and workflow cursor reflect every prior mutation. If the Playwright MCP (or a human in the FAO portal) opens the same quote the active flow is sitting on and advances a step, fills a field, or triggers eligibility:

  1. Progressive's server-side state changes (workflow cursor moves, field values update, eligibility flags flip).
  2. Our backend's next API call uses a payload built from a stale local snapshot of that state.
  3. Progressive rejects the request — bare 500, validation errors, or SaveRaceCondition.

This is not recoverable without re-opening the quote (which resets the cursor to NamedInsured).

Rules

  • NEVER open the active DA-duplicated quote (the one live in the Cursor browser / config-driven flow) in the Playwright MCP. Parity work goes against PROGRESSIVE_POC_PLAYWRIGHT_QUOTE_NUMBER, full stop.
  • NEVER open PROGRESSIVE_POC_PLAYWRIGHT_QUOTE_NUMBER in the Cursor browser. That quote is reserved for dedicated parity/FAO captures in the Playwright MCP.
  • ALWAYS read .env to get PROGRESSIVE_POC_PLAYWRIGHT_QUOTE_NUMBER before launching a Playwright MCP session.
  • If you need a fresh parity quote, create a new one in the FAO portal under our agent credentials and update PROGRESSIVE_POC_PLAYWRIGHT_QUOTE_NUMBER in .env.
  • If the config-driven flow starts returning unexpected 500s after Playwright MCP work, the active DA-duplicated quote was likely touched. Start a fresh DA landing (new sessionId or clear carrier_quote_sessions) rather than trying to unstick the corrupted session.

Parity Work with Playwright MCP (NOT Cursor Browser)

Parity checks against the FAO portal ALWAYS run through user-playwright-mcp. The Cursor IDE browser (cursor-ide-browser) is for exercising our own code changes against the active DA-duplicated quote — it is not for Progressive parity captures, and it must never open the parity quote.

When you need to compare what our code does vs. what Progressive actually does, the Progressive agent portal is the source of truth. Our docs, our payload builders, and even this file may be outdated. Use the Playwright MCP to dump real FAO behavior, then reshape our code to match — never the other way around.

Setup

The Playwright MCP controls a separate Google Chrome instance. All tools are called via CallMcpTool with server: "user-playwright-mcp".

CRITICAL: Use the Playwright Quote — NEVER the Active DA Quote

The Playwright MCP browser MUST use PROGRESSIVE_POC_PLAYWRIGHT_QUOTE_NUMBER exclusively. Opening the currently active DA-duplicated quote in the Playwright MCP will corrupt the server-side state that the config-driven flow depends on. See "Quote Isolation" above.

When opening a quote in the Playwright MCP browser, always read .env to get the value of PROGRESSIVE_POC_PLAYWRIGHT_QUOTE_NUMBER and use that.

CRITICAL: Always Check for an Existing Session First

Before doing ANYTHING — before navigating, before trying to open a quote, before login — you MUST check for an existing Playwright MCP browser session. Progressive login takes ~40-60s, and re-opening a quote resets the workflow cursor. If there's already a logged-in session with a quote open on a specific step, reuse it.

Step 1: List open tabs

CallMcpTool → user-playwright-mcp → browser_tabs → { action: "list" }

Step 2: If a tab exists on the Progressive domain (title contains "FAO", URL contains foragentsonly.com):

CallMcpTool → user-playwright-mcp → browser_snapshot

Check the snapshot to determine current state:

  • If already on a quote step (e.g., Products, Household Members) → skip login, skip quote open, continue from here
  • If on the dashboard or quote list → navigate to the quote from there
  • If on a login page or session expired → proceed with full login flow

Step 3: Only if NO tabs exist, start a fresh session with browser_navigate.

Why this matters:

  • Progressive login is slow (~40-60s including MFA)
  • Re-opening a quote via RouteQuote resets the workflow cursor to NamedInsured
  • The user may have manually navigated to a specific step — don't blow that away
  • A previously authenticated session can stay alive for 30+ minutes

Capture Network Requests from FAO Portal

This is the most important debugging technique. By filling a form step on the real FAO portal and capturing network requests, you can see exactly what API calls Progressive's SPA makes — then compare to our backend.

1. browser_tabs → action: "list"                     # find the FAO tab
2. browser_snapshot # see current page state
3. browser_fill_form → fields: [...] # fill form fields
4. browser_click → ref: "eXX" (the next step button) # submit the step
5. browser_network_requests → { includeStatic: false } # capture API calls
6. browser_take_screenshot # visual confirmation

The browser_network_requests output shows every XHR/fetch the SPA made, including:

  • PUT PropertyAddressEligibility?flow=Validate
  • PUT PropertyQuoteFlowType?flow=Full
  • PUT CurrentWorkflowState?workflowNode=ProductsHO (multiple, as user types)
  • POST NextWorkflowState?workflowNode=ProductsHO

Compare this sequence to our backend logs to identify missing calls.

Capture API Response Bodies

Use browser_run_code to intercept responses:

{
"code": "async (page) => { const responses = []; page.on('response', r => { if (r.url().includes('/api/v1/composite/')) responses.push({ url: r.url(), status: r.status() }); }); await page.waitForTimeout(30000); return responses; }"
}

Take Screenshots for Visual Comparison

CallMcpTool → user-playwright-mcp → browser_take_screenshot → { type: "png", fullPage: true }

Useful for comparing our rendered form against the FAO portal to spot missing fields or UI differences.

Hidden Fields & Stale Value Gotcha

HIDDEN_CUSTOMER_FIELDS in build-step-schema.ts defines fields that exist in the HAL response but are never rendered as form controls:

AgentCode, AsiAgentCode, QuoteOrigin, IsVerified, DisclosureProvided,
HasInternationalAddress, MailingZipType, SessionId, PackageType, PaymentOption

These fields ARE included in buildDefaultValues() (so they end up in form.getValues()), but they have NO Controller component — meaning they never trigger re-renders or receive user-driven updates. Their values come from formValues[q.Property] or q.LastAnswer at initial render time.

The danger: When the user changes a visible field that should also update a hidden field (e.g., SelectedPackagePackageType), the hidden field retains its stale value in the form state. Any API call that includes the full form payload will send the stale hidden value.

Pattern to follow: When dispatching an API call that depends on a hidden field being in sync with a visible field, explicitly override the hidden field in the payload. Do this on both frontend (defense in depth) and backend (authoritative fix). See issue #9 above for the concrete example.

Quick Verification Checklist

When testing a Products step submission end-to-end:

  1. NamedInsured submits: Check logs for POST NextWorkflowState?workflowNode=NamedInsured → 201
  2. Products step-config loads: Check for GET CurrentWorkflowState?workflowNode=ProductsHO → 200
  3. Eligibility validates: PUT PropertyAddressEligibility?flow=Validate → 200
  4. Flow type set: PUT PropertyQuoteFlowType?flow=Full → 200
  5. Fresh state fetched: GET CurrentWorkflowState?workflowNode=ProductsHO → 200 (post-eligibility re-fetch)
  6. Form data saved: PUT CurrentWorkflowState?workflowNode=ProductsHO → 200 (the save step)
  7. Step advances: POST NextWorkflowState?workflowNode=ProductsHO → 201
  8. Next step config: Response contains HouseholdMembers questions

If any step returns non-200/201, check the response body for ValidationDetails or Messages.

Coverages Package Toggle

  1. Page loads CoveragesHO: GET CurrentWorkflowState?workflowNode=CoveragesHO → 200
  2. User changes SelectedPackage: Frontend dispatches POST /step-config/update-package
  3. Backend aligns fields: Log shows updateCoveragePackage: syncId=...
  4. PUT succeeds: PUT UpdatePropertyCoveragePackage?expand=[...] → 200
  5. Response has updated LastAnswer: Inspect response — shield-icon fields have new values
  6. Frontend resets form: form.reset(defaults) fires, all dependent fields update

If the PUT returns 500, dump formData and verify SelectedPackage === PackageType. If they differ, the alignPackageTypeWithSelection function isn't running or the frontend is sending a stale payload.

Environment

VariablePurpose
PROGRESSIVE_POC_PLAYWRIGHT_QUOTE_NUMBERQ-number used by Playwright MCP browser ONLY for parity/FAO captures. The Cursor browser and the config-driven flow must never open this quote.
API_GATEWAY_PROGRESSIVE_PW_HEADLESSChromium headless mode (legacy, not used in HTTP API)

PROGRESSIVE_POC_QUOTE_NUMBER is deprecated. The active quote for the config-driven flow now comes from the DA/GAQ landing URL (?q=Q...) and its Progressive-minted duplicate — see entry-from-da.md. Do not reintroduce this variable in new docs or code.

API Gateway logs: Terminal running pnpm exec nx run fastlane-api-gateway:serve Progressive API file logs: logs/progressive/ directory

FAO Portal Access

EnvironmentURL
QAhttps://62.qa.foragentsonly.com
Productionhttps://www.foragentsonly.com

Credentials are in .env under PROGRESSIVE_* variables. Progressive QA is intermittently unreliable — expect occasional timeouts and 5xx responses (see issue #5).

Replacement Cost Estimate (Silent Backend Auto-Adopt — POD9-361)

After POD9-361 the DTC config-driven flow no longer asks the customer about Dwelling Coverage. Property Details ends at the Interior accordion; the carrier's RCE is silently adopted as Coverage A on the backend and surfaced read-only on the Coverages page.

Flow

  1. Customer fills General Info / Eligibility / Exterior / Interior and clicks Save & Continue on the Interior accordion.
  2. POST NextWorkflowState may fail with 400 + edit ASI00001 on DwellingCoverageValue when the carrier replacement cost guard rejects whatever Progressive defaulted (or RC1 mapper carried over).
  3. progressive-step.service.ts#submitStepRaw catches ProgressiveFieldValidationError, calls retryWithCarrierRce which uses DwellingCoverageReconciliation to extract the carrier RCE from the HAL response, logs Adopting Progressive RCE <value>, and re-enters submitStepRaw once with DwellingCoverageValue: <rce> overlaid on the form data.
  4. A one-shot recursion guard (the skipCarrierRceRetry flag passed through the private submitStepRawWithRetryGuard via submitWithOverride) ensures we never retry twice on the same submit. If the second submit also throws, the original error propagates.
  5. The next HAL state lands on HouseholdMembers with formValues.DwellingCoverageValue reflecting Progressive's RCE; the Coverages page reads this for the read-only Coverage A display.

Why this changed

Earlier iterations surfaced the mismatch to the customer via ReplacementCostConfirmationModal so they could accept or talk to an agent. UX research showed customers got stuck on the modal and abandoned, and the carrier value is the only legally bindable Coverage A value. Per POD9-361, no Dwelling Coverage UI is rendered on Property Details and the value is silently adopted on the backend instead. The deleted plumbing (confirmDwellingReplacementCost, withReplacementCostRejection, the DWELLING_REPLACEMENT_COST arm of ProgressivePendingConfirmation, and the POST step-config/confirm-replacement-cost endpoint) was orphan code after the UI removal and has been deleted.

Domain Boundaries

  • VO: DwellingCoverageReconciliation (libs/apis/carriers/progressive/src/lib/domain/value-objects/dwelling-coverage-reconciliation.ts) owns the invariant: "dwelling coverage must equal carrier replacement cost". Only this VO knows how to recognize the ASI00001 edit and extract the RCE from a HAL response. The HAL extractor enforces a ^\d+(\.\d+)?$ numeric guard so a malformed/malicious carrier string can never land in our retry payload or our log line.
  • Error: ProgressiveFieldValidationError carries the HAL response alongside field edits so retryWithCarrierRce can introspect without re-fetching.
  • Service: ProgressiveStepService.submitStepRaw is the only place that catches this error; retryWithCarrierRce + submitWithOverride is the single re-entry point with a one-shot guard.

Debugging

If a customer reports an unexpectedly high Coverage A:

  1. Grep gateway logs for Adopting Progressive RCE <value> — confirms the silent retry fired and which RCE was adopted.
  2. Inspect the next-step HAL formValues.DwellingCoverageValue — that is the binding number.
  3. If the retry never fires but the rate is higher: the carrier may have bumped on a later step (post-eligibility RCE refresh). Inspect [eligibility:PropertyAddress] and [flowType:PropertyQuote] response bodies for ReplacementCostEstimate.

NamedInsured Step Override — "About You" Accordions

The NamedInsured step renders through the override at apps/fastlane-portal/src/app/pages/carriers/progressive/home/config-driven/step-overrides/named-insured/named-insured-step.tsx instead of the generic config-driven StepForm. The step title becomes "About You" and the UI exposes two accordions with sequential completion: AddressMortgage & Ownership.

This override is a pure presentation change. The payload shape sent to Progressive is byte-identical to the generic renderer: both paths go through the same sanitizeFormPayload(...) against the same HAL-driven allowlist, with no new HAL properties introduced. A parity test in named-insured-step.spec.tsx pins this behavior.

Personal info is hidden but still submitted

Per UX, the personal-info HAL questions (AgentCode, AsiAgentCode, FirstName, MiddleInitial, LastName, Suffix, DateOfBirth, Gender, MaritalStatus, PrimaryEmailAddress, PhoneType, PhoneNumber, DisclosureProvided) are not rendered on the About You page. Their values flow through the form from HAL via buildDefaultValues(...) and are submitted unchanged when the user clicks Save & Continue.

  • useForm({ ..., shouldUnregister: false }) keeps all default-seeded keys in the form state even though no Controller is mounted for them.
  • A component test (named-insured-step.spec.tsx → "still submits personal info HAL defaults to Progressive even though the UI hides them") pins this behavior.
  • If a new personal-info field lands in HAL, it will flow through the same seed-and-submit path with no code change required.

Accordion state machine

StateVisualBehavior
idleDimmed (30% opacity), chevron disabledWaiting for previous accordion to complete
openExpanded, title in primary green, in-card Next buttonUser edits fields; Next triggers form.trigger(...) for that accordion's HAL properties
completeCollapsed with green IconCheckFilled and "Complete" subtitleHeader is clickable to re-expand

Initial state: Address = open, Mortgage = idle. Clicking Next on the Address accordion validates its HAL properties via form.trigger(...), marks it complete, and opens Mortgage & Ownership. The mortgage accordion has no Next button — picking Yes marks it complete, picking No navigates to the knockout page.

Client-side gates (never submitted)

Two client-only pieces of state live in useState outside the react-hook-form store so they cannot leak into sanitizeFormPayload:

  • samePropertyAsMailing — toggles visibility of the HAL MailingAddress / City / State / ZipCode / ApartmentUnit / MailingZipType / HasInternationalAddress input block. When Y, the HAL defaults submitted by the step stay exactly as Progressive returned them — the same behavior as today when the user leaves the flat renderer's mailing inputs untouched. When N, the same inputs become visible and bind to the same HAL properties via react-hook-form, so edits flow through existing sanitization unchanged.
  • hasMortgage — required to arm final submission. Y marks the mortgage accordion complete and allows the page-level Save & Continue to submit. N calls useMortgageKnockoutNavigation() which routes to /carriers/progressive/home/knockout with type: 'generic', a mortgage-specific message, and reasons: ['No mortgage on the property...'].

Neither value is ever added to the form values or the submitted payload.

Label overrides

If UX changes user-facing copy (e.g., RecentlyMoved → "Did you move within the past 2 months?"), update label-overrides.ts only. That map is keyed by HAL Property and consumed at render time by the accordion sections — it never mutates question.Property, question.LastAnswer, question.ValidValues, question.ShouldDisplay, or any HAL data.

Key files

FileRole
named-insured-step.tsxOrchestrator: form setup, accordion state machine, final submit (personal info seeded but not rendered)
field-groupings.tsHAL-question → accordion bucket mapping (Mailing Address / Prior Address)
accordion-status.tsPure state-machine helpers (markAccordionComplete, collapseOtherAccordions, isAllComplete)
label-overrides.tsHAL-property-keyed { label, description } overrides for Figma copy
mortgage-knockout.tsbuildMortgageKnockoutState() + useMortgageKnockoutNavigation()
sections/radio-card-toggle.tsxClient-only Figma-style Yes/No radio card (for samePropertyAsMailing and hasMortgage)
sections/controlled-hal-radio-card.tsxReact-hook-form Controller wrapper over RadioCardToggle for HAL yes/no fields like RecentlyMoved
sections/accordion-card.tsxShell with idle / open / complete visual states

Debugging

  • "About You" page does not render — confirm stepConfig.workflowNode === 'NamedInsured'. The renderer branch lives in renderer/step-renderer.tsx near the Portfolio / People branches.
  • User cannot advance past the Address accordion — open devtools, pick a value for the "Is your property address the same as your mailing address?" toggle; if left empty, handleAddressNext sets a local error and does not advance.
  • Mortgage knockout does not navigate — confirm PROGRESSIVE_HOME_ROUTES.KNOCKOUT is mounted in home/index.tsx (lazy ./knockout).
  • Submitted payload looks different from the flat renderer — run the golden-payload parity test (named-insured-step.spec.tsx → "submits payload byte-identical to sanitizeFormPayload of HAL defaults..."). Any regression that introduces new HAL keys or omits existing ones will surface here.

Property Details Step Override — Eligibility Restoration Contract

The 14 client-side eligibility yes/no answers (twoPlusClaims, trampolineOnProperty, …) live in zustand persisted to sessionStorage. They are NOT submitted as their own HAL fields — Progressive's per-concern flags (TrampolineFlag, PoolFenceFlag, etc.) are auto-defaulted to 'N' for every ProductsHO submit by build-step-schema.ts, and the master flags (VerifyNoInelCond / VerifyNoUwCond) are force-applied to 'Y' by ProductsHoVerificationFlags.applyIfRequired on the gateway. The UI answers exist purely to drive the knockout flow client-side.

The vulnerability and the fix

sessionStorage is per-browser-tab and is wiped when the tab is closed or when the browser kills an idle tab to reclaim memory. The 14 answers were lost on the next visit even though Progressive's server-side state remembered everything else about the quote. We restore them from HAL on Property Details mount instead of forcing the user to re-answer.

Source of truth: EligibilityVerifiedFlag

Progressive emits EligibilityVerifiedFlag (and AddressVerifiedFlag) server-side, set to 'Y' only after a successful PUT PropertyAddressEligibility?flow=Validate. That endpoint is wired into the eligibility accordion's "Next" button. Knockout fires immediately on any Yes answer, so the only reachable state where EligibilityVerifiedFlag === 'Y' is "every one of the 14 answers was No".

EligibilityVerifiedFlag flows through extractFormValues into stepConfig.formValues['EligibilityVerifiedFlag'], so the frontend reads it directly with no gateway changes.

Restoration flow

The HAL check only triggers when zustand is empty for this syncId. A user mid-session whose answers are already in zustand is never overridden, mirroring the snapshot-wins-over-HAL precedence pattern other overrides use.

Trust model

  • HasEdit is NOT "the user touched this". Throughout the codebase HasEdit === true means "Progressive flagged this field with a validation error", not "the user answered it". A perfectly-answered 'N' carries HasEdit: false.
  • The per-concern HAL flags are NOT a reliable signal of user intent. Today every ProductsHO submit silently sends 'N' for every per-concern flag via the auto-default in build-step-schema.ts lines 326-329 (raw === '' && !NO_AUTO_DEFAULT_YES_NO.has(question.Property)'N'). Progressive's server-side state for those flags is therefore always 'N' after the first submit, regardless of what the user picked. They cannot be used to recover the user's actual answers.
  • EligibilityVerifiedFlag === 'Y' IS a reliable signal. Progressive only emits it after our gated validateProductsEligibility call succeeds, which requires every client-side answer to be No (otherwise knockout fires first).

Implementation

FileRole
client-eligibility-questions.tsbuildAllNoEligibilityState()Pure helper that returns a fresh ClientEligibilityState with every key set to 'N'.
property-details-ui-store.tsseedEligibilityAllNoIfEmpty()Store action: writes the all-No seed when every answer is null; no-op (returns the same state reference) when any answer is already non-null.
property-details-step.tsx → mount effectAfter persist hydration finishes and the syncId scope is applied, reads stepConfig.formValues['EligibilityVerifiedFlag'] and calls seedEligibilityAllNoIfEmpty() when it equals 'Y'. Re-runs on syncId or flag-value change.

The submit pipeline is unchanged: sanitizeFormPayload, build-step-schema.ts's empty-string-to-'N' auto-default, and ProductsHoVerificationFlags.applyIfRequired keep their existing contracts. No new HAL fields are added, no new gateway endpoints are introduced, and the knockout page is untouched.

Risks and corner cases

  • EligibilityVerifiedFlag reset by Progressive after a pre-eligibility field change. If Progressive ever resets the flag when the user edits address/residence/etc., the restoration won't trigger and the user re-answers — that's the desired behavior, so this is not a real risk.
  • Stale corrupted state where EligibilityVerifiedFlag === 'Y' but a per-concern flag is 'Y'. Provably unreachable today: knockout fires before the eligibility validate call ever happens with a 'Y' answer in zustand, and our auto-default never produces a 'Y'. We could add a defensive check later if HAL ever surprises us; not in scope.
  • Zustand has stale answers from a prior version of the user's session, but syncId matches. This already works today — ensureScope only wipes on a syncId mismatch. The new restoration is no-op when any zustand answer is non-null, so existing in-flight quotes keep their answers.
  • Migration: existing quotes work either way. The change is additive.

Debugging

  • All 14 cards mount unselected on a quote that already passed eligibility — open devtools, inspect stepConfig.formValues['EligibilityVerifiedFlag']. If absent or empty, Progressive has not emitted the verified flag yet (e.g. the user is on a fresh DA-duplicated quote). If 'Y', check that zustand persist has finished hydrating: usePropertyDetailsUiStore.persist.hasHydrated() must return true before the seed runs.
  • All 14 cards mount with "No" pre-selected on a quote that has not been verified — confirm stepConfig.formValues['EligibilityVerifiedFlag'] === 'Y'. If it's not, check the server-side state via logs/progressive/*_ProductsHO_response.json and confirm Progressive really has not flipped the flag. The seed only runs when the HAL flag is 'Y'.
  • A user re-answered the eligibility questions but on remount the answers are gone — the seed only writes when zustand is empty. If the user closes the tab before any answer is committed (or the browser kills the tab), zustand is wiped and the seed restores all-No. The user's specific intermediate Yes/No mix is not recoverable from HAL — by design, since the only reachable verified state is all-No.

Step persistence (POD9-514)

POD9-514 added a durable forensic record of every successfully advanced Progressive Home config-driven step. Each successful step-config/submit writes a snapshot of the carrier-validated formValues + premiumSummary into the existing fastlane.carrier_quote_sessions.metadata JSONB column as a fire-and-forget side-effect — the HAL response is never delayed by the DB write, and a DB failure never blocks the user flow.

What gets written and where

The write lands under a disjoint metadata.steps subtree, side-by-side with the existing metadata.knockout marker so the two services never interfere:

// fastlane.carrier_quote_sessions.metadata
{
// Pre-existing top-level keys (POD9-514 preserves all of these untouched)
"syncId": "abcd1234-...",
"sessionIndex": "0",
"knockout": { "reasons": [...], "source": "...", "markedAt": "..." },

// NEW (POD9-514)
"steps": {
"NamedInsured": {
"formValues": { /* HAL formValues, PII-stripped */ },
"premiumSummary": null,
"submittedAt": "2026-05-19T15:11:00.000Z"
},
"ProductsHO": {
"formValues": { "ResidenceType": "...", "DwellingCoverageValue": "350000", ... },
"premiumSummary": null,
"submittedAt": "2026-05-19T15:13:42.000Z"
},
"CoveragesHO": {
"formValues": { "PersonalLiability": "500000", "WindHailDeductible": "...", ... },
"premiumSummary": {
"totalPremium": 1842,
"term": "12 months",
"insuredName": "", // ← scrubbed before write (PII)
...
},
"submittedAt": "2026-05-19T15:18:09.000Z"
}
// ... one entry per workflowNode the user has advanced through
}
}

The wire-in lives in apps/apis/fastlane-api-gateway/src/app/controllers/progressive.controller.ts inside submitStepConfig, gated on result.advanced === true. Non-advance submits (validation error, knockout, silent non-advance) carry no validated snapshot worth persisting and are skipped intentionally — mirrors the existing setHomeCoverageFormValues gating directly below it.

Field sourcing — the easy footgun

Persisted fieldSourced fromDo NOT use
formValuesbody.formData — what the user submitted for THIS stepresult.stepConfig.formValues — after a successful advance, that's the NEXT step's HAL LastAnswer defaults, not what the user just answered. Persisting it writes meaningless audit data and defeats the PII filter (the filter is designed for user-submitted answers, not arbitrary HAL carryover).
premiumSummaryresult.stepConfig.premiumSummary — Progressive computes it from everything up to and including this submission and attaches it to the post-advance HALbody.formData — never carries premium data.

Symptom of the wrong sourcing: metadata.steps.NamedInsured.formValues contains property-detail fields the user never saw (LivingArea, RoofMaterial, EligibilityVerifiedFlag, …) and the PII filter has nothing to strip because no PII is present in the wrong source.

PII strip contract

The deny-list is owned by ProgressiveHomeStepPiiFilter and recognized in four key shapes (flat, Drivers.List[n].X, Drivers.List.n.X, driver_n__X) via exact-leaf matching — so look-alike property names such as LastQuoteName are preserved untouched.

CategoryStripped HAL properties
ContactFirstName, MiddleInitial, LastName, Suffix, DateOfBirth, Gender, MaritalStatus, PrimaryEmailAddress, ConfirmPrimaryEmailAddress, DeliveryEmailAddress, PhoneType, PhoneNumber
SSNSocialSecurityNumber
Payment tokensCreditCardName, CreditCardNumber, CreditCardExpirationDate, CreditCardholderZip
Premium summary contactpremiumSummary.insuredName is blanked to '' inside the persistence service before write — the customer's full legal name (from HAL PniFullName) is not persisted in the snapshot
KEPT (despite resembling PII)MortgageeLoanNumber, MortgageeLoanId, MortgageeName, MortgageeAddress*, MortgageeCity, MortgageeState, MortgageeZip — needed by the post-bind Salesforce sync verification path

If ProgressivePremiumSummary ever gains additional contact-like fields (e.g. insuredEmail, insuredPhone), extend stripPremiumSummaryPii in the persistence service AND keep ProgressiveHomeStepPiiFilter's deny-list in sync.

Merge strategy

Two-level shallow merge inside a single prisma.carrierQuoteSession.upsert:

existingMetadata = { knockout?, syncId?, sessionIndex?, steps?: { [node]: { ... } } }

newMetadata = {
...existingMetadata, // preserve knockout / syncId / etc.
steps: {
...(existingMetadata.steps ?? {}), // preserve other completed steps
[workflowNode]: { formValues, premiumSummary, submittedAt }, // latest-wins per step
},
}
  • Latest-wins per workflowNode — back-navigation / re-submit overwrites the entry; no append, no version log, no partial merge inside an entry. Matches AC 2 ("no corruption or stale data is visible for that step").
  • Other steps' entries inside metadata.steps are preserved — completing CoveragesHO does not erase the snapshot from NamedInsured / ProductsHO / etc.
  • metadata.knockout (KO marker) and other top-level keys are preserved — both services target disjoint subtrees.
  • Concurrency caveat: read-modify-upsert is not transactional. A genuine race between two simultaneous writes for different workflowNodes on the same quoteId could drop the loser's metadata.steps entry. In practice improbable because:
    1. The Progressive flow is single-tab / sequential — one accordion at a time, one Save & Continue at a time.
    2. The bookkeeping write is non-blocking and finishes well before the next step's submit fires (the next user click is gated on a network round-trip + Progressive re-render).
    3. The KO marker uses the identical pattern with no observed races in production.
  • If observability later shows the race, promote to atomic jsonb_set or add a per-row version column with optimistic locking. Explicit follow-up, not in scope.

Log fingerprints

[Progressive Home] [PH-STEP-PERSIST] saved: quoteId=<id> workflowNode=NamedInsured fields=N
[Progressive Home] [PH-STEP-PERSIST] skipped (no quoteId resolvable): syncId=<8> sessionIndex=0 workflowNode=NamedInsured
[Progressive Home] [PH-STEP-PERSIST] non-blocking save failed: syncId=<8> workflowNode=NamedInsured: <error>

The [Progressive Home] prefix is added by ProgressiveHomeGatewayLogger; the [PH-STEP-PERSIST] tag is unique to this service. Filter Papertrail by [PH-STEP-PERSIST] to see only the step-persistence events.

SQL inspection queries

Inspect every persisted step for a session, ordered by submit time:

SELECT  step                                       AS "workflowNode",
entry->>'submittedAt' AS submitted_at,
jsonb_pretty(entry->'formValues') AS form_values,
jsonb_pretty(entry->'premiumSummary') AS premium_summary
FROM fastlane.carrier_quote_sessions,
jsonb_each(metadata->'steps') AS s(step, entry)
WHERE carrier = 'PROGRESSIVE'
AND lob = 'HOME'
AND "quoteId" = '<sessionId>'
ORDER BY submitted_at;

Sanity check — which sessions have any persisted steps yet:

SELECT  "quoteId",
"carrierQuoteNumber",
jsonb_object_keys(metadata->'steps') AS step,
"lastApiCall"
FROM fastlane.carrier_quote_sessions
WHERE carrier = 'PROGRESSIVE'
AND lob = 'HOME'
AND metadata ? 'steps'
ORDER BY "lastApiCall" DESC;

How to verify PII is stripped

Run the per-session query above for any session that completed Checkout, then confirm none of the per-step form_values blobs contain FirstName, LastName, DateOfBirth, Gender, MaritalStatus, Primary*Email*, Phone*, SocialSecurityNumber, or CreditCard*. Additionally confirm every premium_summary.insuredName is the empty string "". A regex grep across the query output is sufficient:

psql ... -c "<query above>" | grep -E "FirstName|LastName|DateOfBirth|Gender|MaritalStatus|Primary.*Email|PhoneNumber|SocialSecurityNumber|CreditCard|Drivers\.List\[[0-9]+\]\.FirstName|driver_[0-9]+__SocialSecurityNumber"
# Expect zero matches.

Helpful hints rail does not open

The shared "Helpful Hints" rail covers Property Details, Additional Details, and Coverages (see progressive-ux-presentation-only-config.md → "Helpful Hints Drawer"). It replaces the legacy POD9-395 tooltip/popover, the POD9-319 "Coverage information" sheet, and the dead InfoTooltipIcon static SVG. Symptoms and where to look:

SymptomLikely causeFirst place to look
Clicking the info icon does nothing (no rail, no console error)The active page is not wrapped in ProgressiveHomeHelpfulHintsProvider. useProgressiveHomeHelpfulHints throws "must be used within …" if so, so check the browser console first.shell/flow-shell.tsx — the provider wraps ConfigDrivenFlowInner. If a step renders outside the config-driven shell (knockout / post-bind) the rail is intentionally absent.
Rail opens but the row pre-expansion is wrongTrigger key does not match an entry in PROGRESSIVE_HOME_HELPFUL_HINT_KEYS. The provider's resolveHelpfulHintsKey falls back to null on unknown keys, which renders an empty rail.shell/helpful-hints-content.tsx — every key in PROGRESSIVE_HOME_HELPFUL_HINT_KEYS must have a corresponding entry in the PROGRESSIVE_HOME_HELPFUL_HINTS map. helpful-hints-content.spec.ts enforces this.
Checkout / Portfolio shows the helpful-hints rail instead of PaymentSummaryCardcontextRail precedence in flow-shell.tsx got reordered. Checkout (isCheckoutStep && !isPostBindCheckout) MUST take priority over the rail.shell/flow-shell.tsx — the contextRail = isCheckoutStep ? … : isHelpfulHintsOpen ? … : undefined ternary chain.
Rail stays open across step navigationThe closeHelpfulHints() effect keyed on stepConfig?.workflowNode did not fire. Most likely cause: a new step doesn't change workflowNode (the flow stays on the same node across an intermediate re-render).shell/flow-shell.tsx — the useEffect(() => closeHelpfulHints(), [closeHelpfulHints, stepConfig?.workflowNode]) block. If Progressive ever surfaces multiple distinct sub-screens under the same workflowNode, key the effect on a finer-grained value instead.
LossAssessment row suddenly shows an info iconA future PR added it to the icon map without copy. The coverages-step.spec.tsx regression pin should have caught this.coverages/sections/addon-coverages-accordion.tsxHELPFUL_HINTS_KEY_BY_PROPERTY. LossAssessment is intentionally NOT in that map (PRD body empty).
Rail's right edge no longer lines up with the header's hamburger icon (or the sidebar/form gutters look off vs. the logo)The Progressive Home content gutter drifted from the PortalHeader. The rail (fixed w-[390px]) aligns only because the content container matches the header's max-w-[1512px] + 60px gutter.shell/flow-shell.tsx — the QuoteFlowLayout className="p-0 lg:py-6 lg:px-[60px] max-w-[1512px]". If Save & Continue lands under the rail instead of beside the form, check resolve-footer-class-name.ts's lg:pr-[414px] (= 390 rail + 24px gap). See progressive-ux-presentation-only-config.md → "Rail width & header gutter alignment".

Related debug notes:

  • The rail's UX (mobile bottom sheet vs desktop right-rail, focus restoration, en-dash preservation) is tested in libs/ui/components/src/lib/responsive-helpful-hints.spec.tsx. Failures there usually mean the underlying ResponsiveHelpfulHints component regressed, not the Progressive Home wiring.
  • POD9-319's CoveragesInformationSheet is deleted. If you see a "Coverage information" header button on /coverages, you're running stale build artifacts — rebuild the portal.