Skip to content

fix(chat): structured output vs grounding and output masking - #52

Merged
mml555 merged 4 commits into
mainfrom
fix/structured-output-interactions
Aug 7, 2026
Merged

fix(chat): structured output vs grounding and output masking#52
mml555 merged 4 commits into
mainfrom
fix/structured-output-interactions

Conversation

@mml555

@mml555 mml555 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Closes #50. Closes #51.

Both are interactions between features that landed back to back in this batch and never knew about each other. Verified against the code before fixing, not taken at face value.

#50json_schema + grounding: strict refused every answer

Confirmed exactly as filed. prepare.ts passed responseFormat through at both call sites with no grounding check, while streamSafetyGate sits 250 lines above rejecting the same class of conflict for streaming.

Reproduced: the gateway's prompt demands {found, answer, quotes}; a caller schema constrains the provider to a different shape; verifyGrounding finds none of those keys and fails closed. Every request returns the refusal, after a paid model call, indistinguishable from a genuine "not in the knowledge base".

Now a 400 grounding_response_format_conflict before any provider spend — asserted in the test via a provider-call counter. json_object stays allowed, since grounding already requires JSON.

Not done: the issue suggested also catching this in modelgov validate. responseFormat is a per-request field, not something a feature declares in config, so there is nothing for config validation to inspect. It can only be a request-time gate.

#51 — structured output rewritten by output masking

Real, with two corrections to the report.

It is not silent. mapper.ts already returned safety.piiMasked, so a caller did get a signal that something was masked. The genuine gap is that nothing said the structured payload specifically was rewritten.

The sharpest consequence is understated. A masked value is [REDACTED], so a json_schema response can violate the very schema the caller declared — a field typed as a number, date, or enum comes back as a redaction string. Schema-constrained extraction returning schema-violating data is worse than a missing flag. Documented on the field; the real fix for an extraction feature is #46's per-entity policy allowing the entity types it exists to extract.

What changed:

  • Masking now parses the payload and rewrites string leaves, so the document stays well-formed by construction. A test demonstrates a prose-style pass producing unparseable JSON where the leaf-walk cannot.
  • Object keys are deliberately not masked — they are the caller's schema, not model output.
  • structuredMasked on the response and a structured_masked audit reason code, mirroring /documents' structuredWithheld / structured_withheld.
  • Falls back to prose masking when the model ignored responseFormat, when the payload is a bare scalar, or when the guard cannot batch — never skipping output safety.
  • Fails closed on a mismatched masked-value count rather than pairing a value with the wrong field.

One thing that nearly shipped broken: the flag was set correctly but invisible, because Fastify's response serializer drops fields absent from the declared schema. Caught by dumping the real response instead of trusting the code path.

Verification

pnpm verify green: 1285 tests (up from 1268). 19 new cases across structured-output-masking.test.ts, the chat integration suite (flag + reason code end to end, and absence on prose responses), and the grounding integration suite (conflict rejected with no provider call, json_object still allowed, ungrounded features unaffected).

Summary by CodeRabbit

  • New Features

    • Structured JSON responses now support PII masking while preserving valid JSON and object keys.
    • Responses indicate when structured content was masked.
    • Grounded requests accept json_object formats and clearly reject unsupported json_schema formats.
  • Bug Fixes

    • Improved handling of prose, scalar, blocked, invalid, and unavailable safety-processing results.
    • Budget reservations are released when structured masking fails.
  • Tests

    • Added coverage for structured masking, grounding format validation, response metadata, and audit logging.

mml555 added 2 commits August 7, 2026 11:50
Closes #50.

`grounding: strict` makes the gateway own the system prompt, which demands
{found, answer, quotes}. A caller `json_schema` constrains the provider to
a DIFFERENT shape, so the model returns valid JSON with none of those keys,
verifyGrounding fails closed, and EVERY answer comes back as the refusal —
after a paid model call each time, and indistinguishable from a genuine
"not in the knowledge base" result.

#43 (structured output) and #45 (configurable grounding) landed back to
back and neither knew the other existed. That is my miss: the gateway
owning the prompt is fundamentally incompatible with the caller owning the
schema, and nothing said so.

Now a 400 `grounding_response_format_conflict` before any provider spend,
mirroring streamSafetyGate, which already rejects the same class of
conflict for streaming. `json_object` stays allowed: grounding already
requires JSON, so that agrees with the prompt rather than fighting it.

Not done: the issue also suggested catching this in `modelgov validate`.
responseFormat is a per-REQUEST field, not something a feature declares in
config, so there is nothing for config validation to inspect. It can only
be a request-time gate.
Closes #51.

/chat with responseFormat and output PII masking ran inspectOutput over
the SERIALIZED JSON as if it were prose. Two problems.

Structure: rewriting spans in serialized text can damage the document, not
just its values — a detected span covering a delimiter breaks the parse. A
test demonstrates a prose-style pass producing unparseable JSON where the
leaf-walk cannot. Now the payload is parsed and its string LEAVES are
masked, which keeps it well-formed by construction. Object keys are not
touched: they are the caller's schema, not model output.

Signalling: /documents reports structuredWithheld and a structured_withheld
reason code; /chat reported nothing comparable. Added `structuredMasked` on
the response and a `structured_masked` audit reason code.

Two corrections to the issue as filed. It says the rewrite is silent — it
is not, `safety.piiMasked` was already returned; the gap is that nothing
said the STRUCTURED payload specifically was touched. And it understates
the sharpest consequence: a masked value is `[REDACTED]`, so a json_schema
response can now violate the very schema the caller declared (a number,
date or enum field returning a redaction string). That is documented on
the field, and the real fix for an extraction feature is #46's per-entity
policy allowing the entity types it exists to extract.

Falls back to prose masking when the model ignored responseFormat, when the
payload is a bare scalar, or when the guard cannot batch — never skipping
output safety. Fails closed on a mismatched masked-value count rather than
pairing a value with the wrong field.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 059d074c-7994-4579-b98d-18d3b06c1ff3

📥 Commits

Reviewing files that changed from the base of the PR and between 6010972 and bddb664.

📒 Files selected for processing (1)
  • packages/api/src/modules/chat/structuredOutput.ts

📝 Walkthrough

Walkthrough

Adds JSON-aware PII masking for chat structured responses, masking metadata and audit reasons, plus pre-provider validation for incompatible grounded response formats. Updates response contracts and tests for structured masking, prose fallback, and grounding behavior.

Changes

Chat safety and grounding

Layer / File(s) Summary
Structured JSON masking
packages/api/src/modules/chat/structuredOutput.ts, packages/api/test/structured-output-masking.test.ts
Adds recursive masking of JSON string values, with fallback, blocking, mismatch handling, and parseability tests.
Chat response safety metadata
packages/api/src/modules/chat/pipeline.ts, packages/api/src/modules/chat/mapper.ts, packages/api/src/modules/chat/schemas.ts, packages/api/src/modules/chat/types.ts, packages/api/src/modules/usage/auditLogRepo.ts, packages/api/test/chat.integration.test.ts
Uses structured masking for formatted responses, reports structuredMasked, preserves prose handling, and records structured_masked.
Grounding response-format validation
packages/api/src/modules/chat/prepare.ts, packages/api/test/grounding.integration.test.ts
Rejects json_schema with strict grounding before provider calls while allowing json_object and ungrounded json_schema.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ChatPipeline
  participant maskStructuredOutput
  participant OutputSafety
  participant ChatResponse
  ChatPipeline->>maskStructuredOutput: formatted response content and safety plan
  maskStructuredOutput->>OutputSafety: inspect JSON string leaves
  OutputSafety-->>maskStructuredOutput: masked values and findings
  maskStructuredOutput-->>ChatPipeline: serialized content and masking status
  ChatPipeline->>ChatResponse: structuredMasked and piiMasked safety metadata
Loading

Possibly related PRs

  • mml555/modelgov#43: Introduced the responseFormat structured-output pipeline extended by this change.
  • mml555/modelgov#46: Added related structured-output PII masking patterns and batch safety inspection.
  • mml555/modelgov#45: Added the grounding behavior extended with response-format validation.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary changes to structured output, grounding, and output masking.
Linked Issues check ✅ Passed The changes satisfy the coding objectives in [#50] and [#51], including conflict rejection, structured masking, indicators, audit codes, and safety fallbacks.
Out of Scope Changes check ✅ Passed The implementation and tests remain within the scope of the linked grounding and structured-output masking issues.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/structured-output-interactions

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/api/src/modules/chat/pipeline.ts`:
- Around line 157-171: Update the post-provider error handling around
maskStructuredOutput in the pipeline try block to catch structured-masking
failures, including plain Error instances from mismatched batch and JSON leaf
counts. Route these failures through the existing post-provider failure path so
the audit result is written and settleBilling is called exactly once before
returning the safe failure response.

In `@packages/api/src/modules/chat/structuredOutput.ts`:
- Around line 86-87: Update the structured-output handling around the none
fallback and parsing flow so valid JSON objects or arrays are parsed before
checking inspectOutputMany; when batching is unavailable, withhold or fail
closed instead of applying prose masking. Keep prose fallback only for invalid
or scalar JSON, and add coverage in structured-output-masking.test.ts for the
fail-closed result.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 96008d97-1954-4cf1-a206-ac5ef4ec6a3d

📥 Commits

Reviewing files that changed from the base of the PR and between 86c1083 and 614a68e.

⛔ Files ignored due to path filters (1)
  • packages/api/openapi.json is excluded by !packages/api/openapi.json
📒 Files selected for processing (10)
  • packages/api/src/modules/chat/mapper.ts
  • packages/api/src/modules/chat/pipeline.ts
  • packages/api/src/modules/chat/prepare.ts
  • packages/api/src/modules/chat/schemas.ts
  • packages/api/src/modules/chat/structuredOutput.ts
  • packages/api/src/modules/chat/types.ts
  • packages/api/src/modules/usage/auditLogRepo.ts
  • packages/api/test/chat.integration.test.ts
  • packages/api/test/grounding.integration.test.ts
  • packages/api/test/structured-output-masking.test.ts

Comment thread packages/api/src/modules/chat/pipeline.ts
Comment thread packages/api/src/modules/chat/structuredOutput.ts Outdated
…k JSON

Two Major findings from review, plus a bug my own suite caught while
fixing them.

BILLING LEAK. maskStructuredOutput threw a plain Error on a masked-value
count mismatch, but the pipeline's catch handles SafetyServiceError only —
so it escaped past settleBilling. The provider call had already happened
and cost real money, and the reservation was never settled. Now a
SafetyServiceError, which is semantically right (the guard violated its
contract) and routes through the settling path. An integration test
asserts reserved_usd returns to zero.

PROSE-MASKING VALID JSON. The batch-support check ran BEFORE the parse, so
a guard without inspectOutputMany sent a structured payload down the prose
path — the delimiter-corruption risk this module exists to prevent. Parses
first now and fails closed for objects/arrays, reserving the fallback for
genuinely non-JSON content.

...and that fail-closed branch then fired regardless of the plan, so a
`pii: off` feature that merely asked for structured output 503'd on any
guard lacking the optional batch method. Caught by an existing test of mine
that passes in isolation and fails in the full suite. Now returns early
when the plan would not mask output at all.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/api/src/modules/chat/structuredOutput.ts`:
- Around line 77-80: Update the result contract comment near the
structured-output guard to document that structured: false is also returned when
pii is "off" or piiScope is "input" because masking is a no-op. Preserve the
existing description for non-object JSON and the fail-closed behavior for
unmaskable structured payloads.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1e402db2-8df4-4d18-b129-9e771da63557

📥 Commits

Reviewing files that changed from the base of the PR and between 614a68e and 6010972.

📒 Files selected for processing (3)
  • packages/api/src/modules/chat/structuredOutput.ts
  • packages/api/test/chat.integration.test.ts
  • packages/api/test/structured-output-masking.test.ts

Comment thread packages/api/src/modules/chat/structuredOutput.ts Outdated
structured:false now has two causes, not one — non-object JSON, and a plan
that would not mask output at all. The comment claimed only the first.
@mml555
mml555 merged commit 49eb37f into main Aug 7, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant