Skip to content

fix(playbook-optimizer): stop persisting raw exception text in decision_reason - #476

Open
guangyu-reflexio wants to merge 1 commit into
mainfrom
fix/gepa-decision-reason-leak
Open

fix(playbook-optimizer): stop persisting raw exception text in decision_reason#476
guangyu-reflexio wants to merge 1 commit into
mainfrom
fix/gepa-decision-reason-leak

Conversation

@guangyu-reflexio

@guangyu-reflexio guangyu-reflexio commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What

PlaybookOptimizer wrote a raw exception string into decision_reason, a durable column:

self.storage.update_playbook_optimization_job(
    job.job_id, status="failed", decision_reason=str(exc)
)

str(exc) on an arbitrary exception can carry customer content. That is not hypothetical — on a sibling code path a pydantic.ValidationError raised during playbook analysis embedded customer transcript text, because the model's own output is interpolated into the error message.

decision_reason is read back into the domain model and surfaced to operators.

Why this shape of fix

Every other writer of that column uses a fixed, controlled string — "assistant backend aborted one or more evaluations", "best candidate did not pass commit thresholds", 'retired_by_replay_redesign' — so the column is already a de facto controlled vocabulary and this line was the only outlier. A tenant Postgres trigger also compares it against literals, so the vocabulary is load-bearing rather than cosmetic.

The diagnostic is not lost: the two lines immediately above already bind error_type=type(exc).__name__ into structured logging context and call logger.exception, which records the traceback where it belongs.

The exception class name is deliberately NOT added to the column. It buys nothing that error_tags does not already carry, and it would widen an operator-facing fixed vocabulary into one keyed on third-party exception types (gepa, litellm, pydantic).

Scope

A sweep of 253 candidate expressions across both trees found 86 that reach a durable sink. Only this one is fixed here, on the principle that the defect is content-bearing text written into a column not classified to hold content. Every other DB writer targets a field whose declared purpose is an error string — _agent_runs.last_error, operation_state.error_message, billing_debit_incident.detail. Redacting those is a different design question that reaches into billing and extraction, and was not silently folded in.

The tightest sibling, recorded for follow-up: gepa_adapter.py:257/273 writes rationale=f"Assistant failed: {exc}" into playbook_optimization_evaluations.rationale.

Existing rows

The line has existed since 2026-05-08 (~4 months). The path is opt-in — PlaybookOptimizerConfig.enabled=False by default and it needs an assistant backend configured — so only orgs that deliberately enabled GEPA can hold such a row.

On enterprise Postgres these age out: _retention_gc_retired_optimization_jobs hard-deletes non-pending/running jobs past a cutoff, gated on lineage_gc.enabled (default true) with a 90-day window. So contamination is a rolling window that self-heals. Two caveats: OSS SQLite overrides that hook to return 0, so nothing purges there; and whether any org actually enabled the optimizer is an environment question that was not answered by querying anything.

Tests

102 passed in tests/server/services/playbook_optimizer/, verified on top of main rather than only on the branch it was written on.

The guard asserts a distinctive sentinel from the exception message is absent from the persisted value — a test that merely checks "the message isn't there" passes trivially when the exception has a generic message. Mutation-verified: restoring decision_reason=str(exc) and the f-string variant each turn it red.

Worth noting: the optimizer's exception path had no test coverage at all before this change — the first run died on the shared fake request context lacking org_id. Four months of an untested failure path is presumably how the leak survived review.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented unexpected optimization errors from storing customer or provider response content in job records.
    • Failed optimization runs now show a controlled, generic failure reason.
  • Tests

    • Added coverage to verify sensitive exception details are not persisted.
    • Added safeguards ensuring decision reasons use approved fixed text.

…on_reason

The GEPA optimizer's failure path wrote `str(exc)` into
`playbook_optimization_jobs.decision_reason`. That column is durable, is
`TEXT NOT NULL` in both the SQLite and tenant Postgres schemas, is read
straight back into `PlaybookOptimizationJob`, and is shown to operators --
and an arbitrary exception message can carry customer content. A pydantic
`ValidationError` raised on a provider response renders the model's own
output (itself derived from evidence text) into its message; that was
confirmed on a sibling analysis path.

Every other writer of this column already uses a fixed phrase, so the
column is a de facto controlled vocabulary and this site was the outlier.
It now writes one too. Nothing diagnostic is lost: the `error_tags` block
immediately above already binds `error_type=type(exc).__name__` and
`logger.exception` records the traceback, which is where an unbounded
signal belongs.

The exception CLASS name is deliberately not added to the column either.
It is not customer content, but it would widen an operator-facing fixed
vocabulary into a semi-open one keyed on third-party exception types, and
it is already captured in the tags. This follows the precedent set by the
open-world terminal-failure diagnostic, which records class names under a
reserved metadata key while leaving the persisted reason vocabulary fixed.

Tests:
- a behavioural test raises an exception whose message carries a distinctive
  sentinel and asserts the sentinel reaches neither `decision_reason` nor
  `metadata_json`. Restoring `str(exc)`, and an f-string variant of it, both
  turn it red.
- an AST guard asserts every `decision_reason=` passed by a service-layer
  writer is a fixed string (or a conditional of fixed strings), so a new
  writer interpolating a value fails the build rather than leaking quietly.
  It carries a non-vacuity test that fails if the scan stops finding the
  known writers. It found a real false-positive class on its first run --
  the storage layer's row-to-entity hydration -- which is now excluded and
  documented.

The optimizer test harness gained `org_id` on its fake request context: the
exception path had never been exercised, so the missing attribute had gone
unnoticed.
@coderabbitai

coderabbitai Bot commented Sep 3, 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: CHILL

Plan: Essentials

Run ID: 0d816249-3da1-404a-8f54-ad7c5662bbd7

📥 Commits

Reviewing files that changed from the base of the PR and between 695070a and 3cb0b6e.

📒 Files selected for processing (3)
  • reflexio/server/services/playbook_optimizer/optimizer.py
  • tests/server/services/playbook_optimizer/test_decision_reason_vocabulary_guard.py
  • tests/server/services/playbook_optimizer/test_playbook_optimizer.py

Included review availability: 3 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 4 reviews per hour.


📝 Walkthrough

Walkthrough

The optimizer now stores a fixed reason for unexpected failures. Tests verify that exception content is excluded from durable job data. An AST-based guard enforces fixed-string decision_reason values across server job writers.

Changes

Optimization failure reason

Layer / File(s) Summary
Fixed failure reason handling
reflexio/server/services/playbook_optimizer/optimizer.py, tests/server/services/playbook_optimizer/test_playbook_optimizer.py
Unexpected optimization failures use "optimization run raised an unexpected error". Regression coverage verifies failed status and excludes exception content from the decision reason and metadata.

Decision reason vocabulary guard

Layer / File(s) Summary
Decision reason vocabulary guard
tests/server/services/playbook_optimizer/test_decision_reason_vocabulary_guard.py
An AST scan finds decision_reason writers and rejects dynamic or interpolated values. Non-vacuity checks verify that known writers are detected.

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

Merge Risk: ⚪ Minimal · up to 3cb0b

Failed optimization jobs now retain a controlled failure reason rather than storing arbitrary exception text, reducing exposure of customer-derived content while preserving structured diagnostics and traceback logging. The change is covered by regression and vocabulary-guard tests and is ready to merge.

Suggested reviewers: yyiilluu, yilu331

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 10 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing raw exception text from being persisted in decision_reason.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/gepa-decision-reason-leak

Warning

Your free Security trial is over. An organization admin can upgrade to Advanced for continuous pull request security review or dismiss this notice.


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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant