Skip to content

Wire renderSegments provenance into the parser and retire the strict-mode nonce (cross-runtime) #542

Open

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

  1. Spec: add a structural parse contract that consumes RenderSegment[] (honor role markers only in literal spans; treat markers inside interp spans as literal text) and a @vector set covering the injection cases the nonce currently guards. Land red-first per the 2.0.x workflow.
  2. Runtimes: wire prepare() to render-segments → structural-parse across all 7 runtimes; register jinja_subset (and its siblings) as the active renderer for this path.
  3. Retire the nonce: remove pre_render nonce stamping + strict nonce validation once parse consumes provenance. Keep the injection-defense vectors green throughout.

Why now / relationships

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 inside interp spans are never honored as role boundaries.
  • pre_render nonce 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

  1. sethjuarez commented on Sep 10, 2026

    @sethjuarez
    MemberAuthor

    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_subset already emits real literal/interp provenance with source/strict, and it's conformant across all 7 runtimes via RenderSegmentVectors. Once prepare() 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 renderSegments is a false-provenance stub

    runtime/python/prompty/prompty/renderers/mustache.py implements render_segments by wrapping the entire chevron output as a single literal span. That labels interpolated content as authored template text. Under a structural parser that trusts literal spans, this is an injection bypass — strictly worse than the nonce it would replace. It passes conformance only because RenderSegmentVectors (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:

      tokenize("Hello {{name}}! {{#items}}<{{x}}>{{/items}} bye")
        ('literal','Hello ') ('variable','name') ('literal','! ')
        ('section','items')  ('literal','<')      ('variable','x')
        ('literal','>')      ('end','items')       ('literal',' bye')
      

      A literal token → literal span; text emitted while resolving a variable/section value → interp span with source = <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 RenderSegmentVectors regardless of implementation.

    Recommendation

    Same pattern that made Jinja work: own a small mustache-subset tokenizer that emits provenance, and add Mustache RenderSegmentVectors. Use chevron's token model as the reference shape / golden-generator (oracle) — the way jinja_subset generates 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:

    • A (uniform): every registered format ships an owned provenance engine; opaque renderers are rejected in strict mode.
    • B (two-tier): structural parse for provenance-capable formats; keep the nonce as a fallback floor only for opaque renderers.

    Added acceptance criteria

    • RenderSegmentVectors gains Mustache cases (literal vs {{var}} vs section-emitted interp with source), green across all 7 runtimes.
    • Mustache render_segments emits true per-token provenance (no whole-output literal stub) in all 7 runtimes.
    • A decision is recorded for opaque/* renderers (reject-in-strict vs nonce-fallback).
    • Explicitly: the Jinja path needs no engine changes and drops the nonce entirely once parse consumes segments.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions