Skip to content

fix(logs): redact Customer Content from error/warning logs - #377

Merged
guangyu-reflexio merged 2 commits into
mainfrom
fix/redact-customer-content-logs
Jul 22, 2026
Merged

fix(logs): redact Customer Content from error/warning logs#377
guangyu-reflexio merged 2 commits into
mainfrom
fix/redact-customer-content-logs

Conversation

@guangyu-reflexio

@guangyu-reflexio guangyu-reflexio commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

What

Redacts Customer Content (LLM output snippets, profile text, user queries) from ERROR/WARNING log and breadcrumb sites so it does not reach error monitoring (Sentry) or CloudWatch.

Why

Supports the reflexio.ai managed-platform Privacy Policy representation that Customer Content is not sent to analytics or error-monitoring providers. Before this change, a structured-output parse error logged a ~200-char LLM output snippet at ERROR, and profile text / user queries were attached as breadcrumbs — which made Sentry/CloudWatch a Customer-Data recipient.

Changes

  • _litellm_structured_output.py: drop the content snippet from the parse-error log message (retain raw_content as a structured attribute only)
  • extractor.py, _query_reformulator.py, _document_expander.py: log length/type instead of the content text

Scope

Log-message redaction only — no behavior change to extraction or generation. Paired with the reflexio-enterprise legal-docs PR, which relies on this for its "no Customer Content in logs" representation.

Summary by CodeRabbit

  • Bug Fixes / Security
    • Improved privacy by removing raw AI-generated content from structured parse error messages and JSON decoding warnings.
    • Prevented re-wrapping of earlier structured parse failures during the repair flow, preserving the original error.
    • Reduced log exposure across query reformulation, document expansion, and profile extraction by logging only type names and content lengths (or counts) instead of full values.
    • Parsing, repair, and fallback behaviors remain unchanged.

@coderabbitai

coderabbitai Bot commented Jul 22, 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: Pro Plus

Run ID: 5ee13204-2f58-4fa0-818f-fd0aa22a20cf

📥 Commits

Reviewing files that changed from the base of the PR and between 4ed8d3e and 69ce7d1.

📒 Files selected for processing (4)
  • reflexio/server/llm/_litellm_structured_output.py
  • reflexio/server/services/pre_retrieval/_document_expander.py
  • reflexio/server/services/pre_retrieval/_query_reformulator.py
  • reflexio/server/services/profile/components/extractor.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • reflexio/server/llm/_litellm_structured_output.py
  • reflexio/server/services/profile/components/extractor.py
  • reflexio/server/services/pre_retrieval/_query_reformulator.py

📝 Walkthrough

Walkthrough

Failure errors and warnings across structured-output parsing, document expansion, query reformulation, and profile extraction now omit raw model-generated content while retaining length or value type metadata.

Changes

LLM output redaction

Layer / File(s) Summary
Redact raw output from failure reporting
reflexio/server/llm/_litellm_structured_output.py, reflexio/server/services/pre_retrieval/_document_expander.py, reflexio/server/services/pre_retrieval/_query_reformulator.py, reflexio/server/services/profile/components/extractor.py
Structured-output errors and service warnings no longer include raw invalid model output; they report content length or value type instead.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

🚥 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 accurately captures the main change: redacting customer content from error and warning logs.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
✨ 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/redact-customer-content-logs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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 `@reflexio/server/llm/_litellm_structured_output.py`:
- Around line 259-261: Update the StructuredOutputParseError construction in the
structured-output parsing flow to avoid interpolating the raw exception message
into the error text. Use only a fixed failure code or type(e).__name__, and if
exception chaining is needed, chain a sanitized exception so later error=%s
logging and LiteLLMClientError wrapping cannot expose customer content.

In `@reflexio/server/services/pre_retrieval/_document_expander.py`:
- Around line 156-161: Update the warning log in the document expansion
JSON-parse error path to report the raw model response length using output,
rather than the normalized text buffer. Keep the content omitted and preserve
the existing privacy-safe logging behavior.
🪄 Autofix (Beta)

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: CHILL

Plan: Pro Plus

Run ID: e4dd3a96-6bcf-4e6a-b165-f0d75f182f95

📥 Commits

Reviewing files that changed from the base of the PR and between a0cc10e and b258b7b.

📒 Files selected for processing (4)
  • reflexio/server/llm/_litellm_structured_output.py
  • reflexio/server/services/pre_retrieval/_document_expander.py
  • reflexio/server/services/pre_retrieval/_query_reformulator.py
  • reflexio/server/services/profile/components/extractor.py

Comment thread reflexio/server/llm/_litellm_structured_output.py Outdated
Comment thread reflexio/server/services/pre_retrieval/_document_expander.py Outdated
These log/exception sites interpolated model output or user/profile text into
ERROR/WARNING messages, which flow to Sentry + CloudWatch via the logging bridge
(before_send scrubs request bodies but not log/exception/breadcrumb content).
Log lengths/types instead so no Customer Content reaches error monitoring:
- _litellm_structured_output: drop the 200-char content snippet from the
  StructuredOutputParseError message (raw_content attribute kept for in-process
  repair; it is not serialized to logs).
- profile extractor: log the type, not the raw profile dict.
- query reformulator: log length/type, not the user query text.
- document expander: log length, not the raw model output.
…review-loop)

- structured output: use type(e).__name__ not str(e) in the parse-failure
  message (a json/pydantic error echoes the offending input), and let a
  deliberately-raised StructuredOutputParseError propagate unchanged so the
  content-free repair diagnostic (e.g. 'truncated') is preserved
- profile extractor: log raw-profile COUNT, not the profile dicts (INFO records
  become Sentry breadcrumbs that before_send does not scrub)
- document expander: report raw output length, not the normalized buffer
@guangyu-reflexio
guangyu-reflexio force-pushed the fix/redact-customer-content-logs branch from 4ed8d3e to 69ce7d1 Compare July 22, 2026 14:08
@guangyu-reflexio
guangyu-reflexio merged commit 3d39b3d into main Jul 22, 2026
1 check passed
guangyu-reflexio added a commit that referenced this pull request Jul 27, 2026
A publish of 50 interactions returned 200 and stored 50 rows with
content=''. No profiles were generated and nothing reported an error.
Three defects combined to hide it.

1. InteractionData defaults every field and unknown keys were dropped
   with no trace, so a mis-keyed field yielded a *valid* interaction
   carrying nothing.

2. The precondition guard written to catch exactly this was dead code.
   UserActionType is a StrEnum whose NONE member is the truthy string
   "none", so `not interaction_data.user_action` was always False and
   the four-way "all empty" chain could never fire. Ten lines above,
   the same function already used the correct `!= UserActionType.NONE`.
   A test pinned the broken behaviour as correct, which is why it
   survived; that test is now inverted.

3. On the default async path the rejection is discarded --
   add_user_interaction runs inside a BackgroundTask whose return value
   was dropped, after the caller already got 200 "queued". A
   success=False is not an exception, so it was not logged either.

The per-interaction rules therefore live in a model_validator on
PublishUserInteractionRequest, which runs during request parsing and so
yields a 422 on both the sync and the background-task path.
InteractionData.validation_error() holds all three rules (emptiness,
user_action-needs-description, image-url-xor-encoding) in one place and
precondition_checks delegates to it, so the layers cannot diverge and
the two rules that previously existed only on the discarded path are
now reportable. carries_content() counts every content-bearing field --
tools_used, shadow/expert content, citations, retrieved_learnings --
because a narrower list would reject legitimate tool-call-only and
shadow-mode turns, and it strips text so "   " is not content.

Unknown keys are reported, not rejected. extra="forbid" was implemented
first and reverted: both first-party plugins build their wire payload
with a denylist, so every turn carries request-level bookkeeping such as
user_id. Forbidding it raised, the plugin adapter swallowed the
exception, and its publish watermark never advanced -- so the same batch
retried forever and nothing was ever published, for every installed
plugin, the moment the server deployed. That is strictly worse than the
bug being fixed. Unknown keys are now captured, stripped so they cannot
reach storage, and their NAMES (never values, per #377) returned in the
response warnings[] and logged once server-side.

Also:
- openclaw plugin builds its wire dict from an allowlist of
  InteractionData fields instead of a three-key denylist, with a
  contract test pinning the allowlist to the real model and asserting
  every emitted turn validates.
- The background rejection log withholds response.message, which on the
  storage path is an unbounded str(e) from a catch-all that Sentry would
  ingest as an unscrubbed event body.
- AI_AGENT_INTEGRATION.md no longer tells integrators to hold the
  watermark on every failure -- that guidance is what turns a 4xx into a
  permanent wedge.
- Fixes a route-test fixture that was itself posting user_message/
  agent_message/interaction_type -- none of which are InteractionData
  fields -- and asserting 200 on the resulting empty interaction.

BREAKING CHANGE: an interaction carrying nothing at all, a user_action
without a user_action_description, or both interacted_image_url and
image_encoding, now returns 422 instead of being accepted and silently
dropped. Unknown fields are still accepted but are now reported in
warnings[].
guangyu-reflexio added a commit that referenced this pull request Jul 27, 2026
A publish of 50 interactions returned 200 and stored 50 rows with
content=''. No profiles were generated and nothing reported an error.
Three defects combined to hide it.

1. InteractionData defaults every field and unknown keys were dropped
   with no trace, so a mis-keyed field yielded a *valid* interaction
   carrying nothing.

2. The precondition guard written to catch exactly this was dead code.
   UserActionType is a StrEnum whose NONE member is the truthy string
   "none", so `not interaction_data.user_action` was always False and
   the four-way "all empty" chain could never fire. Ten lines above,
   the same function already used the correct `!= UserActionType.NONE`.
   A test pinned the broken behaviour as correct, which is why it
   survived; that test is now inverted.

3. On the default async path the rejection is discarded --
   add_user_interaction runs inside a BackgroundTask whose return value
   was dropped, after the caller already got 200 "queued". A
   success=False is not an exception, so it was not logged either.

The rules live in a model_validator on PublishUserInteractionRequest,
which runs during request parsing and so applies on both the sync and
the background-task path. InteractionData.shape_error() holds the
contradictions (user_action-needs-description, image-url-xor-encoding)
that previously existed only on the discarded path, and
precondition_checks delegates to it so the layers cannot diverge.

Two rules deliberately do NOT hard-fail, both because a stricter version
was implemented, reproduced as catastrophic, and reverted:

- Unknown keys are reported, not rejected. extra="forbid" broke every
  publish from both first-party plugins: they build their wire payload
  with a denylist, so each turn carries request-level bookkeeping such
  as user_id. The adapter swallows the resulting error and never
  advances its publish watermark, so the same batch retries forever and
  nothing is published -- for every installed plugin, the moment the
  server deploys. Unknown keys are now captured, stripped so they cannot
  reach storage, and their NAMES (never values, per #377) reported in
  warnings[]. Nested payload models capture the same way, which surfaces
  ToolUsed.status -- a field both plugins send on every tool call and
  that was being dropped silently.

- An individual empty interaction is skipped, not fatal. Both plugins
  append an empty Assistant placeholder unconditionally, so failing the
  batch wedged them exactly as above -- rejecting the real user turn
  beside the placeholder. A batch where *every* interaction is empty is
  still a 422, and that is precisely the incident (50 of 50 rows).

carries_content() counts every content-bearing field -- tools_used,
shadow/expert content, citations, retrieved_learnings -- because a
narrower list would reject legitimate tool-call-only and shadow-mode
turns, and it strips text so "   " is not content.

Also:
- Warning volume is bounded: unknown key names are caller-controlled and
  uncapped, and 2000 long keys produced a 411 KB warning echoed into
  both the response and a single log record. Now capped at 5 truncated
  names plus a count.
- Request-level keys callers duplicate per-interaction (user_id,
  session_id) are stripped but not warned about, so a correct
  claude-smart batch emits 0 warnings rather than one per turn.
- Both background-task log lines withhold their reason string. On the
  storage path that is an unbounded str(e) from a catch-all, and Sentry
  ingests ERROR records as event bodies without scrubbing them.
- openclaw plugin builds its wire dict from an allowlist of
  InteractionData fields, with a contract test pinning the allowlist to
  the real model and asserting every emitted turn validates.
- AI_AGENT_INTEGRATION.md no longer tells integrators to hold the
  watermark on every failure -- that guidance is what turns a 4xx into a
  permanent wedge. It now splits retryable from non-retryable.
- Fixes a route-test fixture that was itself posting user_message/
  agent_message/interaction_type -- none of which are InteractionData
  fields -- and asserting 200 on the resulting empty interaction.

BREAKING CHANGE: a publish where every interaction is empty, an
interaction whose user_action has no user_action_description, or one
setting both interacted_image_url and image_encoding, now returns 422
instead of being accepted and silently dropped. Unknown fields and
individually-empty interactions are still accepted, and are now reported
in the response's warnings[].
guangyu-reflexio added a commit that referenced this pull request Jul 27, 2026
A publish of 50 interactions returned 200 and stored 50 rows with
content=''. No profiles were generated and nothing reported an error.
Three defects combined to hide it.

1. InteractionData defaults every field and unknown keys were dropped
   with no trace, so a mis-keyed field yielded a *valid* interaction
   carrying nothing.

2. The precondition guard written to catch exactly this was dead code.
   UserActionType is a StrEnum whose NONE member is the truthy string
   "none", so `not interaction_data.user_action` was always False and
   the four-way "all empty" chain could never fire. Ten lines above,
   the same function already used the correct `!= UserActionType.NONE`.
   A test pinned the broken behaviour as correct, which is why it
   survived; that test is now inverted.

3. On the default async path the rejection is discarded --
   add_user_interaction runs inside a BackgroundTask whose return value
   was dropped, after the caller already got 200 "queued". A
   success=False is not an exception, so it was not logged either.

The rules live in a model_validator on PublishUserInteractionRequest,
which runs during request parsing and so applies on both the sync and
the background-task path. InteractionData.shape_error() holds the
contradictions (user_action-needs-description, image-url-xor-encoding)
that previously existed only on the discarded path, and
precondition_checks delegates to it so the layers cannot diverge.

Two rules deliberately do NOT hard-fail. Stricter versions of both were
implemented, reproduced as catastrophic, and reverted -- in each case
because the first-party plugins build their wire payload with a denylist
and buffer an empty Assistant placeholder unconditionally, while their
adapters swallow the error and never advance the publish watermark. The
same batch then retries forever and nothing is ever published, for every
installed plugin, the moment the server deploys:

- Unknown keys are reported, not rejected. They are captured, stripped so
  they cannot reach storage, and their NAMES (never values, per #377)
  returned in warnings[]. Nested payload models capture the same way,
  which surfaces ToolUsed.status -- a field both plugins send on every
  tool call that was being dropped silently.

- An individual empty interaction is skipped, not fatal. A batch where
  *every* interaction is empty is still a 422, and that is precisely the
  incident (50 of 50 rows).

Warnings are built against the caller's ORIGINAL list, before empty rows
are filtered out. Computing them afterwards defeated the feature in its
primary case: a mis-keyed content yields an empty interaction, so the row
carrying the typo was exactly the row removed and its warning vanished,
leaving the caller "skipped 1 empty interaction" with no idea which field
was wrong -- and every surviving index was renumbered.

Warning output is bounded on three axes (per-name length, names per
interaction, total entries) and control characters are stripped, because
the names are caller-controlled and reach both the response body and a
shared multi-tenant log stream. Unbounded, 1000 interactions produced a
~350 KB warning and one enormous log record; now ~2.5 KB.

carries_content() counts every content-bearing field -- tools_used,
shadow/expert content, citations, retrieved_learnings -- because a
narrower list would reject legitimate tool-call-only and shadow-mode
turns, and it strips text so "   " is not content.

Also:
- Request-level keys callers duplicate per-interaction (user_id,
  session_id) are stripped but not warned about, so a correct
  claude-smart batch emits 0 warnings rather than one per turn.
- Both background-task log lines withhold their reason string. On the
  storage path that is an unbounded str(e) from a catch-all, and Sentry
  ingests ERROR records as event bodies without scrubbing them.
- openclaw plugin builds its wire dict from an allowlist of
  InteractionData fields. The drift guard lives in tests/, not the
  plugin's own suite: testpaths excludes that directory and no workflow
  runs it, so an assertion there would never have executed.
- AI_AGENT_INTEGRATION.md replaces "hold the watermark on every failure"
  with a per-status table -- hold on 5xx/timeout/408/429, hold and
  escalate on 401/403, advance only on 400/422 -- since a blanket hold is
  what turns a rejection into a permanent wedge.
- Fixes a route-test fixture that was itself posting user_message/
  agent_message/interaction_type -- none of which are InteractionData
  fields -- and asserting 200 on the resulting empty interaction.

BREAKING CHANGE: a publish where every interaction is empty, an
interaction whose user_action has no user_action_description, or one
setting both interacted_image_url and image_encoding, now returns 422
instead of being accepted and silently dropped. Unknown fields and
individually-empty interactions are still accepted, and are now reported
in the response's warnings[].
guangyu-reflexio added a commit that referenced this pull request Jul 27, 2026
A publish of 50 interactions returned 200 and stored 50 rows with
content=''. No profiles were generated and nothing reported an error.
Three defects combined to hide it.

1. InteractionData defaults every field and unknown keys were dropped
   with no trace, so a mis-keyed field yielded a *valid* interaction
   carrying nothing.

2. The precondition guard written to catch exactly this was dead code.
   UserActionType is a StrEnum whose NONE member is the truthy string
   "none", so `not interaction_data.user_action` was always False and
   the four-way "all empty" chain could never fire. Ten lines above,
   the same function already used the correct `!= UserActionType.NONE`.
   A test pinned the broken behaviour as correct, which is why it
   survived; that test is now inverted.

3. On the default async path the rejection is discarded --
   add_user_interaction runs inside a BackgroundTask whose return value
   was dropped, after the caller already got 200 "queued". A
   success=False is not an exception, so it was not logged either.

The rules live in a model_validator on PublishUserInteractionRequest,
which runs during request parsing and so applies on both the sync and
the background-task path. InteractionData.shape_error() holds the
contradictions (user_action-needs-description, image-url-xor-encoding)
that previously existed only on the discarded path, and
precondition_checks delegates to it so the layers cannot diverge.

Two rules deliberately do NOT hard-fail. Stricter versions of both were
implemented, reproduced as catastrophic, and reverted -- in each case
because the first-party plugins build their wire payload with a denylist
and buffer an empty Assistant placeholder unconditionally, while their
adapters swallow the error and never advance the publish watermark. The
same batch then retries forever and nothing is ever published, for every
installed plugin, the moment the server deploys:

- Unknown keys are reported, not rejected. They are captured, stripped so
  they cannot reach storage, and their NAMES (never values, per #377)
  returned in warnings[]. Nested payload models capture the same way,
  which surfaces ToolUsed.status -- a field both plugins send on every
  tool call that was being dropped silently.

- An individual empty interaction is skipped, not fatal. A batch where
  *every* interaction is empty is still a 422, and that is precisely the
  incident (50 of 50 rows).

Warnings are built against the caller's ORIGINAL list, before empty rows
are filtered out. Computing them afterwards defeated the feature in its
primary case: a mis-keyed content yields an empty interaction, so the row
carrying the typo was exactly the row removed and its warning vanished,
leaving the caller "skipped 1 empty interaction" with no idea which field
was wrong -- and every surviving index was renumbered.

The client merges its own locally-detected warnings into the response.
publish_interaction builds InteractionData before model_dump(), so the
unknown keys are already stripped and the server never sees them; without
the merge, unrecognised fields were reported over raw HTTP but invisible
through the SDK, which is the primary integration path.

Warning output is bounded on three axes (per-name length, names per
interaction, total entries) and control characters are stripped, because
the names are caller-controlled and reach both the response body and a
shared multi-tenant log stream. Unbounded, 1000 interactions produced a
~350 KB warning and one enormous log record; now ~2.5 KB.

carries_content() counts every content-bearing field -- tools_used,
shadow/expert content, citations, retrieved_learnings -- because a
narrower list would reject legitimate tool-call-only and shadow-mode
turns, and it strips text so "   " is not content.

Also:
- Request-level keys callers duplicate per-interaction (user_id,
  session_id) are stripped but not warned about, so a correct
  claude-smart batch emits 0 warnings rather than one per turn.
- Both background-task log lines withhold their reason string. On the
  storage path that is an unbounded str(e) from a catch-all, and Sentry
  ingests ERROR records as event bodies without scrubbing them.
- openclaw plugin builds its wire dict from an allowlist of
  InteractionData fields. The drift guard lives in tests/, not the
  plugin's own suite: testpaths excludes that directory and no workflow
  runs it, so an assertion there would never have executed.
- AI_AGENT_INTEGRATION.md replaces "hold the watermark on every failure"
  with a per-status table -- hold on 5xx/timeout/408/429, hold and
  escalate on 401/403, advance only on 400/422 -- since a blanket hold is
  what turns a rejection into a permanent wedge.
- Fixes a route-test fixture that was itself posting user_message/
  agent_message/interaction_type -- none of which are InteractionData
  fields -- and asserting 200 on the resulting empty interaction.

BREAKING CHANGE: a publish where every interaction is empty, an
interaction whose user_action has no user_action_description, or one
setting both interacted_image_url and image_encoding, now returns 422
instead of being accepted and silently dropped. Unknown fields and
individually-empty interactions are still accepted, and are now reported
in the response's warnings[].
guangyu-reflexio added a commit that referenced this pull request Jul 27, 2026
A publish of 50 interactions returned 200 and stored 50 rows with
content=''. No profiles were generated and nothing reported an error.
Three defects combined to hide it.

1. InteractionData defaults every field and unknown keys were dropped
   with no trace, so a mis-keyed field yielded a *valid* interaction
   carrying nothing.

2. The precondition guard written to catch exactly this was dead code.
   UserActionType is a StrEnum whose NONE member is the truthy string
   "none", so `not interaction_data.user_action` was always False and
   the four-way "all empty" chain could never fire. Ten lines above,
   the same function already used the correct `!= UserActionType.NONE`.
   A test pinned the broken behaviour as correct, which is why it
   survived; that test is now inverted.

3. On the default async path the rejection is discarded --
   add_user_interaction runs inside a BackgroundTask whose return value
   was dropped, after the caller already got 200 "queued". A
   success=False is not an exception, so it was not logged either.

The rules live in a model_validator on PublishUserInteractionRequest,
which runs during request parsing and so applies on both the sync and
the background-task path. InteractionData.shape_error() holds the
contradictions (user_action-needs-description, image-url-xor-encoding)
that previously existed only on the discarded path, and
precondition_checks delegates to it so the layers cannot diverge.

Two rules deliberately do NOT hard-fail. Stricter versions of both were
implemented, reproduced as catastrophic, and reverted -- in each case
because the first-party plugins build their wire payload with a denylist
and buffer an empty Assistant placeholder unconditionally, while their
adapters swallow the error and never advance the publish watermark. The
same batch then retries forever and nothing is ever published, for every
installed plugin, the moment the server deploys:

- Unknown keys are reported, not rejected. They are captured, stripped so
  they cannot reach storage, and their NAMES (never values, per #377)
  returned in warnings[]. Nested payload models capture the same way,
  which surfaces ToolUsed.status -- a field both plugins send on every
  tool call that was being dropped silently.

- An individual empty interaction is skipped, not fatal. A batch where
  *every* interaction is empty is still a 422, and that is precisely the
  incident (50 of 50 rows).

Warnings are built against the caller's ORIGINAL list, before empty rows
are filtered out. Computing them afterwards defeated the feature in its
primary case: a mis-keyed content yields an empty interaction, so the row
carrying the typo was exactly the row removed and its warning vanished,
leaving the caller "skipped 1 empty interaction" with no idea which field
was wrong -- and every surviving index was renumbered.

The client merges its own locally-detected warnings into the response.
publish_interaction builds InteractionData before model_dump(), so the
unknown keys are already stripped and the server never sees them; without
the merge, unrecognised fields were reported over raw HTTP but invisible
through the SDK, which is the primary integration path.

Warning output is bounded on three axes (per-name length, names per
interaction, total entries) and control characters are stripped, because
the names are caller-controlled and reach both the response body and a
shared multi-tenant log stream. Unbounded, 1000 interactions produced a
~350 KB warning and one enormous log record; now ~2.5 KB.

carries_content() counts every content-bearing field -- tools_used,
shadow/expert content, citations, retrieved_learnings -- because a
narrower list would reject legitimate tool-call-only and shadow-mode
turns, and it strips text so "   " is not content.

Also:
- Request-level keys callers duplicate per-interaction (user_id,
  session_id) are stripped but not warned about, so a correct
  claude-smart batch emits 0 warnings rather than one per turn.
- Both background-task log lines withhold their reason string. On the
  storage path that is an unbounded str(e) from a catch-all, and Sentry
  ingests ERROR records as event bodies without scrubbing them.
- openclaw plugin builds its wire dict from an allowlist of
  InteractionData fields. The drift guard lives in tests/, not the
  plugin's own suite: testpaths excludes that directory and no workflow
  runs it, so an assertion there would never have executed.
- AI_AGENT_INTEGRATION.md replaces "hold the watermark on every failure"
  with a per-status table -- hold on 5xx/timeout/408/429, hold and
  escalate on 401/403, advance only on 400/422 -- since a blanket hold is
  what turns a rejection into a permanent wedge.
- Fixes a route-test fixture that was itself posting user_message/
  agent_message/interaction_type -- none of which are InteractionData
  fields -- and asserting 200 on the resulting empty interaction.
- ToolUsed gains a declared `status` field. Both plugins already send it
  ("success"/"error", derived from the tool response); it was being
  discarded, losing real signal, and once nested capture began reporting
  unknown keys it became the single largest warning source -- one per
  tool-bearing turn. Declaring it recovers the data and removes the noise.
- The skipped-empty summary is appended AFTER the entry cap, so the cap can
  never drop it. Capping the combined list swallowed "N interactions were
  dropped" whenever there were >= 20 unknown-field warnings, which is the
  one thing the caller most needs to know about such a batch.
- The openclaw adapter captures publish_interaction's return value and logs
  any warnings. It previously discarded the result, so the integration that
  motivated this work could not see its own dropped fields.
- The SDK-side test moved to tests/client/, its mirror location, using the
  real ReflexioClient constructor instead of __new__ plus stubs; plus a test
  pinning the premise the client-side merge rests on (the wire payload
  carries no unknown keys, so warnings cannot double).

BREAKING CHANGE: a publish where every interaction is empty, an
interaction whose user_action has no user_action_description, or one
setting both interacted_image_url and image_encoding, now returns 422
instead of being accepted and silently dropped. Unknown fields and
individually-empty interactions are still accepted, and are now reported
in the response's warnings[].
guangyu-reflexio added a commit that referenced this pull request Jul 27, 2026
A publish of 50 interactions returned 200 and stored 50 rows with
content=''. No profiles were generated and nothing reported an error.
Three defects combined to hide it.

1. InteractionData defaults every field and unknown keys were dropped
   with no trace, so a mis-keyed field yielded a *valid* interaction
   carrying nothing.

2. The precondition guard written to catch exactly this was dead code.
   UserActionType is a StrEnum whose NONE member is the truthy string
   "none", so `not interaction_data.user_action` was always False and
   the four-way "all empty" chain could never fire. Ten lines above,
   the same function already used the correct `!= UserActionType.NONE`.
   A test pinned the broken behaviour as correct, which is why it
   survived; that test is now inverted.

3. On the default async path the rejection is discarded --
   add_user_interaction runs inside a BackgroundTask whose return value
   was dropped, after the caller already got 200 "queued". A
   success=False is not an exception, so it was not logged either.

The rules live in a model_validator on PublishUserInteractionRequest,
which runs during request parsing and so applies on both the sync and
the background-task path. InteractionData.shape_error() holds the
contradictions (user_action-needs-description, image-url-xor-encoding)
that previously existed only on the discarded path, and
precondition_checks delegates to it so the layers cannot diverge.

Two rules deliberately do NOT hard-fail. Stricter versions of both were
implemented, reproduced as catastrophic, and reverted -- in each case
because the first-party plugins build their wire payload with a denylist
and buffer an empty Assistant placeholder unconditionally, while their
adapters swallow the error and never advance the publish watermark. The
same batch then retries forever and nothing is ever published, for every
installed plugin, the moment the server deploys:

- Unknown keys are reported, not rejected. They are captured, stripped so
  they cannot reach storage, and their NAMES (never values, per #377)
  returned in warnings[]. Nested payload models capture the same way,
  which surfaces ToolUsed.status -- a field both plugins send on every
  tool call that was being dropped silently.

- An individual empty interaction is skipped, not fatal. A batch where
  *every* interaction is empty is still a 422, and that is precisely the
  incident (50 of 50 rows).

Warnings are built against the caller's ORIGINAL list, before empty rows
are filtered out. Computing them afterwards defeated the feature in its
primary case: a mis-keyed content yields an empty interaction, so the row
carrying the typo was exactly the row removed and its warning vanished,
leaving the caller "skipped 1 empty interaction" with no idea which field
was wrong -- and every surviving index was renumbered.

The client merges its own locally-detected warnings into the response.
publish_interaction builds InteractionData before model_dump(), so the
unknown keys are already stripped and the server never sees them; without
the merge, unrecognised fields were reported over raw HTTP but invisible
through the SDK, which is the primary integration path.

Warning output is bounded on three axes (per-name length, names per
interaction, total entries) and control characters are stripped, because
the names are caller-controlled and reach both the response body and a
shared multi-tenant log stream. Unbounded, 1000 interactions produced a
~350 KB warning and one enormous log record; now ~2.5 KB.

carries_content() counts every content-bearing field -- tools_used,
shadow/expert content, citations, retrieved_learnings -- because a
narrower list would reject legitimate tool-call-only and shadow-mode
turns, and it strips text so "   " is not content.

Also:
- Request-level keys callers duplicate per-interaction (user_id,
  session_id) are stripped but not warned about, so a correct
  claude-smart batch emits 0 warnings rather than one per turn.
- Both background-task log lines withhold their reason string. On the
  storage path that is an unbounded str(e) from a catch-all, and Sentry
  ingests ERROR records as event bodies without scrubbing them.
- openclaw plugin builds its wire dict from an allowlist of
  InteractionData fields. The drift guard lives in tests/, not the
  plugin's own suite: testpaths excludes that directory and no workflow
  runs it, so an assertion there would never have executed.
- AI_AGENT_INTEGRATION.md replaces "hold the watermark on every failure"
  with a per-status table -- hold on 5xx/timeout/408/429, hold and
  escalate on 401/403, advance only on 400/422 -- since a blanket hold is
  what turns a rejection into a permanent wedge.
- Fixes a route-test fixture that was itself posting user_message/
  agent_message/interaction_type -- none of which are InteractionData
  fields -- and asserting 200 on the resulting empty interaction.
- ToolUsed gains a declared `status` field. Both plugins already send it
  ("success"/"error", derived from the tool response); it was being
  discarded, losing real signal, and once nested capture began reporting
  unknown keys it became the single largest warning source -- one per
  tool-bearing turn. Declaring it recovers the data and removes the noise.
- The skipped-empty summary is appended AFTER the entry cap, so the cap can
  never drop it. Capping the combined list swallowed "N interactions were
  dropped" whenever there were >= 20 unknown-field warnings, which is the
  one thing the caller most needs to know about such a batch.
- The openclaw adapter captures publish_interaction's return value and logs
  any warnings. It previously discarded the result, so the integration that
  motivated this work could not see its own dropped fields.
- The SDK-side test moved to tests/client/, its mirror location, using the
  real ReflexioClient constructor instead of __new__ plus stubs; plus a test
  pinning the premise the client-side merge rests on (the wire payload
  carries no unknown keys, so warnings cannot double).
- ToolUsed gains a declared `status` field, coerced rather than validated.
  Both plugins already send it ("success"/"error"); it was being discarded,
  and once nested capture began reporting unknown keys it was the largest
  single warning source. Declaring it strictly turned five previously-
  harmless values (int, None, bool, dict, >100 chars) into a 422 for the
  WHOLE batch, which the plugin adapters swallow without advancing their
  watermark -- reintroducing the exact stall that made extra="forbid"
  unacceptable. A mode="before" validator coerces and truncates instead.
- request_id is sanitised before it reaches a log line. It is a NonEmptyStr
  with no length cap and no character restrictions, so a newline in it could
  forge a line in a shared multi-tenant log stream that Sentry ingests --
  the same hazard as the unknown field names, on the value beside them.
- The openclaw adapter's warning diagnostics sit outside the try that decides
  the publish result, and cannot raise. Inside it, a failure while merely
  formatting a warning would be read as a publish failure and stall the
  watermark on an already-successful publish.
- cap_warning_list returns a copy; the caller appends to it.

BREAKING CHANGE: a publish where every interaction is empty, an
interaction whose user_action has no user_action_description, or one
setting both interacted_image_url and image_encoding, now returns 422
instead of being accepted and silently dropped. Unknown fields and
individually-empty interactions are still accepted, and are now reported
in the response's warnings[].
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