Published on

The Parse-Boundary Ownership Patterns

Authors

Pattern #7 in the HUGO Catalog. See the Registry-Driven Field Projection Pattern article for what this builds on, and the introduction for the catalog framing.

This is the eighth and final pattern in the HUGO catalog, and it's the most boundary-specific one in the set. Everything before it governed how application content flows into the graph, or how the same fields travel back out through history and the stream. This one governs a single seam: the point where the framework reads, or constrains, the structured output the model produced. It's the one place where the field names themselves are the thing in dispute.

The field name that didn't belong

The response parser's actual job is mechanical. It reads the model's output, finds the summary block, pulls the JSON tail, groups the citation tokens, and suppresses the structural markers from the visible text. None of that work requires knowing that the summary is called "key points" in this product, or that one JSON key is "gray area modules." But the code knew anyway. When you write a parser for one product, you name the things you extract in that product's words.

The cost is the same one this whole catalog keeps circling. A second product can't reuse the parser because the parser's output shape is wedded to the first product's vocabulary. And the coupling was invisible. Nobody declared "this parser is medical-specific." It just was, on the strength of a half-dozen field names buried three functions deep. That's the worst kind of coupling, the kind the introduction warned about: not the explicit import you can grep for, but the domain assumption sitting unannounced inside a file the directory structure called core.

Here's the part that makes this one article instead of a footnote. Two sub-problems hide under the single leak, and they pull in opposite directions.

The first is reading an open format. Most of the parser reads whatever the model produced: a forgiving wire grammar of tagged summary blocks, a fenced JSON tail, inline citation tokens. The framework has to be permissive here, because the model doesn't always emit exactly what you asked for, and a parser that falls over on a missing tag is useless in production.

The second is constraining a closed format. One node does the reverse. The post-generation node hands the model a strict schema and demands exactly that field set back. There the framework isn't reading an open format. It's defining a closed one.

Those two sub-problems are why getting the domain names out of the core takes two patterns. One for when the framework reads, one for when it constrains. This article is about both, and about the difference between them.


Pattern A: Allow-Mode Carrier + App Mapper

Name. Allow-Mode Carrier + App Mapper. When the framework reads an open wire format, have it yield neutral grammar primitives and let one app-supplied mapper be the only place domain names exist.

Intent. Keep the framework's grammar (how to read the model's output format) and the application's vocabulary (what the extracted fields are named and mean) on opposite sides of a single registered mapper. The payoff is twofold: the parser is reusable by any product, and every domain field name lives in exactly one app-owned file.

Structure. Three pieces.

First, a framework-neutral grammar primitive. A frozen dataclass holding exactly what the wire grammar yields, with zero domain names:

@dataclass(frozen=True)
class ParseGrammarPrimitives:
    """Framework-neutral values the wire grammar yields, before app naming.

    The parser produces these; an app mapper names domain fields from them.
    """
    response: str
    summary: str
    follow_up_questions: list[str]
    citations_map: dict[str, list[str]] | None
    json_payload: dict

Read the names. summary, not key_points. citations_map, not citations. json_payload, not gray_area_analysis. These are grammar terms: "the thing in the summary block," "the map of citation tokens," "whatever was in the JSON fence." The framework can name those honestly without knowing any product's domain, because they describe the format, not the meaning.

Second, an allow-mode carrier. This is the object the rest of the framework passes around. It types only the one generic field and leaves room for the app's:

class StructuredResponse(BaseModel):
    model_config = ConfigDict(extra="allow")
    response: str

extra="allow" is particularly important. The framework types response, because every product has visible answer text, and nothing else. Domain fields land in the model's extra space.

Third, a registered mapper. A Callable[[ParseGrammarPrimitives], StructuredResponse] the app supplies at bootstrap. The framework's default names no domain field at all:

StructuredOutputMapper = Callable[[ParseGrammarPrimitives], StructuredResponse]

def _default_structured_output_mapper(p: ParseGrammarPrimitives) -> StructuredResponse:
    """Framework default: names no domain field; carries only `response`."""
    return StructuredResponse(response=p.response)

def register_structured_output_mapper(mapper): ...

Real example. The application's mapper is the entire domain-naming surface. Five lines that translate grammar into vocabulary:

def app_structured_output_mapper(p: ParseGrammarPrimitives) -> StructuredResponse:
    return StructuredResponse(
        response=p.response,
        key_points=p.summary,                                  # vocabulary <- grammar
        citations=p.citations_map,
        follow_up_questions=p.follow_up_questions,
        gray_area_analysis=p.json_payload.get("gray_area_analysis", []),
    )

This is the whole point of the pattern in one file. This is the only place in the entire system where summary → key_points and citations_map → citations exist. Every domain field name the leak had buried inside the parser now lives here, in the application, in one place, declared once. Put the framework's neutral ParseGrammarPrimitives and the app's mapper side by side and the grammar/vocabulary split is no longer an argument. It's a diff you can read.

Consequences.

What it enables:

  • The parser is product-agnostic. A second product registers its own mapper and reuses the grammar untouched.
  • Every domain field name lives in one app-owned file. One grep, one point of change.
  • This is a textbook anti-corruption layer. The framework's model of the wire format never leaks the app's domain model, and the app's vocabulary never leaks back into the framework.

What it costs. The carrier is extra="allow", so parsed.key_points resolves dynamically from extra space rather than from a typed attribute. Convenient, and more dynamic than it looks. The honest location of every domain field is extra. Your IDE won't autocomplete parsed.key_points, and your type-checker won't catch a typo in it. I'll come back to this in the close, because it's the one place a careful reader pushes back, and the push is fair.

When not to use it. If your framework only ever serves one product and you have no intention of extracting it, the mapper indirection is overhead. Name the fields directly and move on. The pattern is useful once a second consumer, or a clean platform/app boundary, enters the picture.


Pattern B: Forbid-Mode Schema Provider

Name. Forbid-Mode Schema Provider. When the field set IS the prompt contract, a permissive carrier is impossible, so let the app provide the exact schema the LLM must fill.

Intent. The same goal as Pattern A, keeping domain field names out of the framework, but for the opposite boundary direction. Here the framework constrains the model to emit exactly one field set, so there's no open format to read and no room for a neutral carrier. The app has to supply the schema itself.

Structure. The post-generation node asks the model for a structured output where the field set is a strict contract. The model must emit exactly these keys, no more. That makes a permissive extra="allow" carrier the wrong shape, because you can't leave room for the app's fields when the app's fields are the entire schema. So the framework provides a generic base and a provider seam:

class PostGenerationStructuredOutputBase(BaseModel):
    """Generic post-generation schema. Apps subclass to add their fields."""
    model_config = ConfigDict(extra="forbid")

PostGenSchemaProvider = Callable[[], type[PostGenerationStructuredOutputBase]]

def get_post_gen_schema() -> type[PostGenerationStructuredOutputBase]:
    if _post_gen_schema_provider is not None:
        return _post_gen_schema_provider()
    logger.warning("no provider registered; using empty base schema")
    return PostGenerationStructuredOutputBase  # generic empty base

The node calls get_post_gen_schema() and feeds the result into with_structured_output(...) before the model call. The framework never names a field. It only knows three things: there is a schema, the app provides it, and the model must fill it exactly.

Real example. The app registers a subclass that adds its one field:

class AppPostGenerationOutput(PostGenerationStructuredOutputBase):
    follow_up_questions_end_of_answer: list[str] = Field(default_factory=list)

extra="forbid" is inherited from the base, so the model emits exactly this field set. Registered via register_post_gen_schema_provider(lambda: AppPostGenerationOutput) at bootstrap. The app declares its required output shape in one subclass, and the post-generation node never learns what's in it.

Consequences.

What it enables:

  • The post-generation node is product-agnostic, same as Pattern A's parser.
  • The contract is enforced. With extra="forbid", a drifting model that emits an extra key fails loudly instead of silently. That's the right behavior when the field set is a contract; a quietly-dropped key in a contract is a bug you find in production, and a loud failure is one you find in a test.

What it costs. There's no default behavior worth having. An unregistered provider yields an empty base, so the framework logs a warning and proceeds with no domain fields at all. Pattern A's default ("carry only response") is genuinely useful on its own; this one isn't. A missing registration here means the post-gen node produces nothing domain-specific, so in practice the registration is mandatory, not optional. The warning is the framework admitting it can't do this job alone.

When not to use it. If the model output is forgiving and you want to tolerate extra or missing keys, you're in Pattern A's world, not this one. Forbid-mode is specifically for "this exact field set, no more, no less."


Allow vs. forbid: one boundary, two directions

The contrast is the reason these two ship together. State it as a rule you can carry:

Use an allow-mode carrier plus an app mapper when the framework is reading an open wire format the model produced. Use a forbid-mode schema provider when the framework is constraining exactly what the model may emit.

The table that makes it stick:

Allow-Mode Carrier (A)Forbid-Mode Schema Provider (B)
Boundary directionFramework reads an open formatFramework constrains a closed format
model_configextra="allow"extra="forbid"
Who carries domain namesAn app mapper (grammar → vocabulary)An app schema subclass (the field set itself)
Framework defaultUseful (carry only response)Empty base (registration effectively required)
What "the app owns"The translation from neutral primitivesThe entire schema the model must fill
Drift behaviorPermissive (extra/missing keys tolerated)Strict (extra keys fail loudly)
Real seamregister_structured_output_mapperregister_post_gen_schema_provider

What the table can't quite say is this. Both patterns reach the identical architectural goal: the app owns the vocabulary, the framework owns the grammar. The mechanism differs because the direction in which trust flows across the boundary differs. When the framework is the reader, it can't know the field set ahead of time, so it stays permissive and delegates the naming to a mapper. When the framework is the constrainer, the field set is precisely the thing it's enforcing, so the only sound move is to let the app hand over the schema.

Teaching them as a pair is what gives each one its edge. On its own, "use extra='allow' and a mapper" looks like one arbitrary choice among several. Set against its forbid-mode mirror, it becomes obvious why, and you walk away with a decision rule instead of two disconnected tricks. That's worth more than either pattern alone, which is exactly why neither half is article-sized by itself.

It's also the same move the rest of the catalog has been making the whole time: the application declares, the platform consumes. Here it's applied to the one boundary where the field names themselves were the leak. The direction of the boundary just changes the shape of the seam.


What this pair doesn't do, and where the catalog lands

A few things this pattern pair doesn't pretend to be.

Start with the allow-mode caveat, said plainly. Pattern A buys reusability with a little dynamism. StructuredResponse(extra="allow") means parsed.key_points resolves from the model's extra space, not from a declared attribute. The downstream code that reads it works fine; the response node pulls each domain field with a getattr(parsed, effect.parsed_field) driven by the skill-output registry, which is happy to look in extra space. But a reader inspecting StructuredResponse sees only response: str and might reasonably wonder where key_points comes from. The answer is that it lives in extra, put there by the app's mapper. That's a real tradeoff, not a free lunch, so name it instead of hiding it behind the convenience.

Then there's what these patterns don't touch at all. They govern field naming at the parse boundary, not the parser's grammar. If your wire format changes, a new kind of structural marker or a different citation token shape, that's framework work and neither pattern helps you. They also don't address how the named fields travel through the rest of the system once they exist. That's the previous pattern's job, Registry-Driven Field Projection, which gets the field set to agree across the read and write paths. The two fit together cleanly: that pattern got the field set to agree, this one governs the field meaning at the boundary where the set is first read. Citations is the field that ties them. It travels write → read → stream through that registry, and it gets its name right here, at the boundary, via this slot's mapper.

There's one more app-owned seam in this neighborhood, and I'm deliberately leaving it for another article. The post-generation node also takes a context provider, where the app supplies both a prompt-template fragment and the state-to-value mapping that fills it. That's a richer form of Prompt Slot Injection, and it belongs with the Tiered Config Cascade and Prompt Slot Injection patterns, not here. Pulling it into the allow/forbid contrast would blur the single sharp lesson this article exists to teach.

And that closes the initial catalog. Eight patterns, and one move underneath every one of them: the application declares, the platform consumes, and everything the application supplies is a named record registered at bootstrap rather than a fact the core had to import. The blueprint declared topology. The contracts declared tools. The registry declared the response envelope and, turning around, the read projections. And here, at the last boundary, the application declares the very names of the fields the model produces, on whichever side of the seam trust happens to flow.

The door stays open. There are patterns I know are real and haven't written yet, and there's a second product now putting pressure on parts of this platform the first one never stressed, which is the surest way to find the next one. If you build it before I do, I'd like to read about it.