← Back to blog
Ambiguity isn't resolved once. It's resolved in layers.

Four Layers of Ambiguity in One SFCC Ticket

A public SFCC cartridge shows how four layers of refinement caught contradictions, iterator leaks, and prompt bugs that passing unit tests could not see.

RDY-26 was the sort of ticket I would be glad to pull into a sprint. The refinement pass opened by calling it “exceptionally well-specified.”

That was a fair reading.

The ticket was also contradictory, carried an acceptance criterion nothing could satisfy, and would have let its main feature become a silent partial no-op while every test passed.

That is the useful part. A strong ticket can still hold several kinds of ambiguity, because “refined” is not a final state. Each pass sees only what its own vantage point makes visible.

This was our own work, in a repository anyone can read. Bridge-GPT/Agentic-Commerce-Cartridge is Apache-2.0, and describes itself as a way to “Check if your Salesforce Commerce Cloud site is ready for agentic commerce integration.” It is a free tool, built with the Bridge MCP server and Claude Code, which wrote its tickets, clarifying questions, critiques, plans, and reviews to disk as files rather than leaving them in a chat window.

One caveat before the quotes. Those refinement artifacts stayed in our working tree and never went to the public branch, so where I quote a review below, you are reading our internal record. What is public is everything downstream: the pull requests, the merged code, and the commit messages, where each fix explains its own cause in detail. For a Commerce Cloud developer, that second half is the part you can check without taking my word for it.

The feature sounded contained

RDY-26 added int_data_readiness_overlay, the third cartridge in the repository. Its job was to create a fixed set of recommender personas before an analysis job ran.

Until then, every per-product LLM call generated its own fresh personas, so they drifted inside a single run. RDY-26 sampled 10 products, generated the set once, and passed it into each product evaluation.

The pull request changed 20 files and reported 13,767 added lines, a number that needs its unglamorous footnote: 12,256 of those came from two package-lock.json files. Source, tests, docs, and README were about 1,511. Open to merged in roughly eight hours, with a human review before merge.

Volume was not the hard part. The hard part was turning one plausible instruction into behavior that held across cartridges, generators, Business Manager routing, and LLM prompts. That took four layers.

Layer 1: refine the ticket against the repository

The first pass found the standard implementation hazards: cross-cartridge require paths, the hooks.json schema, a missing CI matrix entry, exact mock keys for proxyquire, where errors should be caught.

Useful work. Necessary work.

It also made one bad call. The review said generator cleanup was intrinsic here. It was not, and the mistake sat there untouched until somebody traced the control flow instead of the ticket's shape.

That is the first limit of refinement. A review can make a ticket internally tidy while misreading a runtime detail. The prose gets cleaner and the iterator stays open.

Layer 2: ask a different model family to disagree

The composite review runs a second pass from a different model family, then produces an accept-or-reject evaluation for each finding. That second pass found the contradiction that mattered most.

The ticket said the overlay should stop per-product persona regeneration. It also put the existing per-product prompt file out of scope. That file still told the model to “Generate 3-5 specific user personas.”

The evaluation spelled out the result. The model would see “both run-level personas AND a directive to invent fresh ones,” which defeats the consistency the ticket existed to create.

So the overlay would supply stable personas while the main prompt asked for replacements. The hook would run, context would pass, unit tests would confirm both, and the behavior the ticket existed to stop would continue anyway. Every component correct in isolation; the contradiction living between two instruction sources, owned by neither.

The same review found the impossible acceptance criterion. The handler had to return null for an empty product scope without calling productScopeHelper.resolveProducts, but the only way to learn the scope was empty was to build that generator and probe it. The ticket demanded the knowledge before the operation that produces it.

Then came the iterator dispute.

Round one had said cleanup was intrinsic. Round two traced the SFCC SeekableIterator objects into finally blocks that ran only when the generator finished or received .return(). The proposed handler used a manual gen.next() loop; it did neither.

Round two was right.

One more finding crossed a cartridge boundary. The ticket asked for a 30-second service timeout, but that timeout lived in int_llm_integration/metadata/services.xml as <timeout-millis>120000</timeout-millis>, on a service definition shared with every other consumer. A one-line overlay instruction had concealed a change to somebody else's runtime.

Layer 3: hand judgment back to a person

The review did not try to settle every issue through model consensus. Its resolution guide split findings into items that could be handled at a desk and items that “needs a conversation.”

Three decisions went to a person, each with the trade-off written down. Should the handler also gate on aiEnabled, given that future automation could build a payload the current form never would? Should that shared 120-second timeout move, hiding a blast radius inside an overlay ticket? Should retry parity land now or wait?

The recommendations were yes, no, and wait. A later commit, 2021fb5, added callWithRetry anyway, which is the right relationship to have with a written recommendation.

This is where the tooling earned its keep. It found the fork, mapped what each branch would cost, and made the question small enough to answer over coffee. It did not pretend a service-wide timeout was just another lint finding.

Layer 4: run the thing

Refinement did not prevent bugs. Three smoke-test corrections followed.

First, commit ca55b1e put the override directive into the system message for the LLM call that generated the run-level personas. It needed to go into the per-product call that consumed them. The sentence was right; the prompt was wrong.

Next, e053d70 found that the hook block had been prepended to the correct system message. The conflicting instruction to generate new personas still appeared later. The commit recorded the diagnosis plainly: “LLMs recency-weight later instructions, so the main prompt won.”

The fix moved hook context to the end and renamed its envelope to:

--- ADDITIONAL CONTEXT (overrides instructions above where they conflict) ---

Override semantics now lived in the shared envelope, where future hook authors could inherit them.

Finally, 508fac5 fixed a subtler drift. The model used the supplied personas but reordered them for each product. An ordering instruction had to be added.

The shipped directive now reads:

var PER_PRODUCT_OVERRIDE_DIRECTIVE =
    'Use the personas defined above when evaluating this product. '
    + 'Do NOT generate new personas. '
    + 'Reference each persona by name in your analysis when explaining product fit, '
    + 'and present them in the same order they appear above '
    + '(PERSONA 1 first, PERSONA 2 second, and so on).';

Read that constant as a compact incident log. “Do NOT generate” came from the contradiction. “Same order” came from smoke testing. The location and envelope came from seeing which instruction won in the full prompt.

Human PR review then confirmed the iterator warning, one level deeper than anyone had predicted. Adding gen.return() at the call site was not enough, because productScopeHelper delegated to its inner generators with manual .next() loops, and a consumer's .return() signal never reaches those. The fix was yield* in three places. The source comment is worth keeping nearby for SFCC generator work:

“yield* (not a manual .next() loop) is required so an early consumer .return() propagates into the inner generator and triggers its productIterator.close() finally block.”

The same review caught a platform type detail: product.shortDescription is MarkupText, not a string, so String(MarkupText) kept the raw tags and text like <p>This jacket features...</p> was leaking into the persona prompt.

After all of it, int_data_readiness_overlay had 45 passing tests. Tests are not the problem. They simply do not see prompt order, generator delegation, or a platform type pretending to be a string.

The sibling ticket with the HTTP 200 clue

The sibling ticket shows the same pattern in more familiar Business Manager plumbing. RDY-22 added per-user saved AI prompts, keyed by session.userName, on the existing DataReadinessUserPreference custom object type:

<attribute-definition attribute-id="savedAiPrompts">
    <display-name xml:lang="x-default">Saved AI Prompts</display-name>
    <description xml:lang="x-default">JSON-encoded map of agent key -> saved prompt text per BM user.</description>
    <type>text</type>
    <mandatory-flag>false</mandatory-flag>
    <externally-managed-flag>false</externally-managed-flag>
    <externally-defined-flag>false</externally-defined-flag>
</attribute-definition>

The controller exported SavePrompt correctly and 85 controller tests passed. On the sandbox, Save returned HTTP 200 with a text/html body: the Business Manager chrome page. No breakpoint fired. No controller log appeared. The missing artifact was one line in bm_extensions.xml:

<pipeline name="ValidateProductData-SavePrompt" />

Without that entry in the matching <menuaction> block, the BM router never invoked the controller at all. The diagnostic is sharp enough to keep: when a BM JSON endpoint answers with HTTP 200 and text/html, check routing before you set another breakpoint in code that may never run.

Refinement is a stack

The four layers did four different jobs. Repository-aware refinement found structural gaps. A second model family challenged the first review and exposed the contradiction inside it. A person made the calls that were genuinely calls. Runtime showed where the assembled system diverged from the design everyone had agreed on. None of them made the next one redundant.

That is what I would carry into the next Commerce Cloud ticket. Do not ask whether the ambiguity is resolved. Ask which kind is resolved, and which vantage point has not looked yet.

Setup and safety notes

Repository work dates from May 2026, reviewed in August 2026, on Claude Code with @bridge_gpt/mcp-server 0.2.36.

SFCC writes are restricted to developer sandboxes, and non-sandbox instances are rejected outright. Log queries require an explicit environment and time range, with retrieval, filtering, and redaction all running server-side, so raw logs never reach the model. Client configuration files are git-ignored because they can carry the Bridge key; .bridge/config holds no secrets by design.

The ticket looked finished on day one. Four layers later, its final directive could tell you exactly where it had been wrong.

About the author

Brian Case headshot

Brian Case

Principal Salesforce Architect & AI Strategist

Brian Case is a Salesforce CTA and AI architect helping Salesforce orgs adopt LLMs, Data Cloud, and Agentforce.