Repository navigation
Wire renderSegments provenance into the parser and retire the strict-mode nonce (cross-runtime) #542
Description
Summary
The provenance-tagged render IR (renderSegments → RenderSegment[]) is fully specced and conformant across all 7 runtimes (#493), but nothing consumes it. The live prepare() pipeline still renders to a flat string and re-parses it, relying on a random per-render nonce to tell authored role markers apart from ones injected via template variables. The nonce is a placeholder for the "structural parser" the render IR was explicitly designed to enable. This issue tracks wiring the segment provenance into the parse stage and retiring the nonce.
Current state
Render side — DONE (#493). schema/model/operations/pipeline/renderer.tsp defines:
alias RenderSegmentKind = "literal" | "interp";
model RenderSegment {
kind: RenderSegmentKind; // literal = from template source; interp = from an input value
text: string;
source?: string; // for interp spans, the root input property the value came from
strict?: boolean; // whether the originating input property is declared strict
}
renderSegments(agent: Agent, template: string, inputs: Record<unknown>): RenderSegment[];The spec comment states the intent directly: "That provenance lets a downstream structural parser distinguish an [authored role marker from an injected one]." There is a @vector(RenderSegmentVectors) set and passing conformance in all 7 runtimes. In Python the reference engine is runtime/python/prompty/prompty/jinja_subset/ (owned tokenizer → parser → evaluator), whose __init__.py states it is "additive and inert … does NOT replace the current flat-string render→parse contract. Wiring it into the pipeline … is Phase 3+."
Parse side — MISSING. schema/model/operations/pipeline/parser.tsp still only defines:
parse(agent: Agent, rendered: string, context?: Record<unknown>): Message[];i.e. it consumes a flat string plus context (the nonce). No segment-consuming parse contract exists. So the live pipeline (core/pipeline.py prepare()/parse()) never touches render_segments; it renders stock jinja2 → string → nonce-validated re-parse. The render vectors still assert the string form (nonce_pattern).
The nonce is a workaround for a lossy boundary
Because render flattens to a string, the parser cannot tell an authored user: marker from one that appeared inside an interpolated variable — so pre_render() stamps every authored marker with a secrets.token_hex nonce and strict parse rejects markers lacking it. With RenderSegment provenance, an injected marker sits in an interp span and is inert by construction — structural prevention instead of after-the-fact detection with a shared secret. (StrictViolation additionally lets the engine reject strict inputs that introduce structural boundaries at render time.)
Proposed work
- Spec: add a structural parse contract that consumes
RenderSegment[](honor role markers only inliteralspans; treat markers insideinterpspans as literal text) and a@vectorset covering the injection cases the nonce currently guards. Land red-first per the 2.0.x workflow. - Runtimes: wire
prepare()to render-segments → structural-parse across all 7 runtimes; registerjinja_subset(and its siblings) as the active renderer for this path. - Retire the nonce: remove
pre_rendernonce stamping + strict nonce validation once parse consumes provenance. Keep the injection-defense vectors green throughout.
Why now / relationships
- The strict-mode nonce just produced a real, shipped bug: all-digit nonces with a leading zero were int-coerced and falsely rejected as prompt injection (Quoted all-digit nonce with a leading zero causes false strict-mode mismatch #520, fixed in fix(python): exempt strict-mode nonce from numeric coercion (#520) #541). That fix is correct and load-bearing until this lands.
- This is the "de-duplicate jinja-subset / shrink the authored surface" direction of Shrink the authored surface: TypeSpec as single source of truth, hand-write only what conformance requires #511 — the render half is already done, so this is the natural follow-through on the parse half.
Acceptance criteria
- A segment-consuming structural parse contract + vectors exist in
parser.tsp, green across all 7 runtimes. prepare()renders to segments and parses structurally in all 7 runtimes; markers insideinterpspans are never honored as role boundaries.pre_rendernonce stamping and strict nonce validation are removed; injection-defense conformance stays green.
Related: #493 (render-side segments + conformance), #511 (shrink authored surface / jinja-subset), #520 + #541 (nonce coercion bug + fix).
Activity
sethjuarez commentedon Sep 10, 2026
Follow-up: per-format provenance is not uniform — Mustache needs its own engine, Jinja does not
Wiring the parse side (above) is sufficient for Jinja on its own:
jinja_subsetalready emits realliteral/interpprovenance withsource/strict, and it's conformant across all 7 runtimes viaRenderSegmentVectors. Onceprepare()consumes segments and parses structurally, Jinja requires no additional engine work and the nonce is fully retired for the Jinja path — provenance makes injected markers inert by construction.The remaining exposure is every other format, and Mustache is the concrete case today.
Mustache
renderSegmentsis a false-provenance stubruntime/python/prompty/prompty/renderers/mustache.pyimplementsrender_segmentsby wrapping the entire chevron output as a singleliteralspan. That labels interpolated content as authored template text. Under a structural parser that trustsliteralspans, this is an injection bypass — strictly worse than the nonce it would replace. It passes conformance only becauseRenderSegmentVectors(schema/model/conformance/vectors/render-segments.tsp) is jinja2-only (5 cases, zero Mustache) — the stub passes by omission."Can we project provenance from the Mustache library's own IR?"
Partly, per-runtime — but it's the wrong cross-runtime foundation.
Python/chevron: yes, concretely.
chevron.tokenizer.tokenize()is public and yields a clean, projectable token stream:A
literaltoken →literalspan; text emitted while resolving avariable/sectionvalue →interpspan withsource = <root key>. Mustache is easier to make injection-safe than Jinja — interpolation is pure key-lookup, no arbitrary expressions.But do not depend on lib internals at runtime. Mustache spans 7 runtimes on 7 different libs (chevron, mustache.js, GRMustache, …). Token models differ, some expose no public IR, and the edges (sections, lambdas, standalone-tag/whitespace handling, partials) vary per implementation. Depending on each lib's internal IR = 7 fragile projections against private APIs = exactly the 7× authored-surface problem Shrink the authored surface: TypeSpec as single source of truth, hand-write only what conformance requires #511 is trying to shrink.
The strong Mustache render vectors don't transfer. They pin flat-string render equivalence (output text), not literal/interp attribution. Provenance still needs its own Mustache
RenderSegmentVectorsregardless of implementation.Recommendation
Same pattern that made Jinja work: own a small
mustache-subsettokenizer that emits provenance, and add MustacheRenderSegmentVectors. Use chevron's token model as the reference shape / golden-generator (oracle) — the wayjinja_subsetgenerates its goldens — but keep the shipped code free of chevron internals. Grammar is tiny (literal / variable / section / inverted / partial / comment), so this is cheap.Opaque
*(CustomTool-style / unknown renderers)Any renderer that can't emit honest provenance must not be trusted by the structural parser. Two options to decide as part of this issue:
Added acceptance criteria
RenderSegmentVectorsgains Mustache cases (literal vs{{var}}vs section-emitted interp withsource), green across all 7 runtimes.render_segmentsemits true per-token provenance (no whole-outputliteralstub) in all 7 runtimes.*renderers (reject-in-strict vs nonce-fallback).