Skip to content

fix(rpc): honor Zod output normalization at the Tauri boundary #627

Description

@ShiboSheng

Description

While diagnosing the rendered sidebar pagination failure in #572, we found a pre-existing contract problem in the shared Tauri RPC layer.

Rust returns native session categories using wire values such as:

{ "category": "agent" }

The frontend output schema intentionally transforms those wire values into the canonical dispatch categories used by the application:

agent -> rust_agent
cli   -> cli_agent
human -> human_session

However, typedInvoke calls output.safeParse(transformed) only as a development-time check and discards the successful parsed.data. It then returns the original transformed value:

if (output && isDev) {
  const parsed = output.safeParse(transformed);
  // parsed.data is not returned
}

return transformed;

As a result, Zod output transforms, preprocessors, defaults, and normal object parsing semantics are described by the TypeScript return type but are not applied to the runtime value.

This behavior has existed since the shared RPC wrapper was introduced; #572 did not create it. The new native sidebar pagination path exposed it because the returned page was loaded successfully but its rows still carried the Rust wire category agent, while the frontend grouping code looked for rust_agent.

Steps to Reproduce

  1. Define an RPC output schema that transforms a backend value:

    const Output = z.object({
      category: z
        .enum(["agent"])
        .transform(() => "rust_agent" as const),
    });
  2. Mock the Tauri command response as:

    { "category": "agent" }
  3. Call the procedure through typedInvoke.

  4. Inspect the returned runtime value.

The TypeScript return type and a direct Output.parse(...) both report category: "rust_agent", but typedInvoke returns category: "agent".

The same behavior can be reproduced with a Zod output .default(...) or z.preprocess(...): direct schema parsing applies the change, but the actual RPC call does not.

Expected Behavior

  • The runtime value returned by an RPC call matches the procedure's declared output type.
  • Output normalization has the same semantics in development and production builds.
  • If output schemas are allowed to transform values or provide defaults, the RPC layer returns the parsed result.
  • If output schemas are validation-only by design, mutation-capable Zod features are prohibited there and all required normalization is performed by an explicit always-running mapper.

Actual Behavior

  • RPC inputs use parsed.data, so input transforms and defaults work.
  • Explicit procedure-level .transform(...) functions run before validation and work.
  • Zod output schema transformations/defaults/preprocessors do not affect the returned value.
  • Development builds log invalid output but still return it.
  • Production builds skip output parsing entirely for performance.
  • TypeScript therefore promises a post-parse output shape that the runtime does not guarantee.

Confirmed User-Visible Impact

Session aggregate category routing

Affected procedures:

  • session_aggregate_list
  • session_native_sidebar_page

Affected schema:

  • SessionAggregateRecordSchema

Rust returns cli | agent | os | human, while the frontend schema declares cli_agent | rust_agent | human_session.

During #572 verification, a native sidebar page could load successfully but fail to render because an agent row did not match the frontend's rust_agent grouping filters. This presented as a Load more click that fetched data without displaying a new row.

The #572 branch currently contains a narrow safety normalization in the session conversion boundary. That workaround fixes the sidebar behavior but does not change the shared RPC contract.

Other Affected Output Schemas

The following are currently latent or compatibility risks rather than confirmed second user-visible regressions.

Domain Intended Zod behavior that is currently discarded Current practical risk
Session event/history z.preprocess(...) converts legacy string/null/primitive args and result values into records Rust currently performs similar normalization, but an older or alternate response path can still expose a runtime shape that contradicts the TypeScript type
Turn metadata Missing modifiedFiles, resourceInteractions, and gitArtifacts default to [] Current Rust responses populate these vectors; a partial or cross-version response could cause .length, .filter, or iteration failures
MCP Missing mcpServers, disabled, and scope receive safe defaults Current Rust structs normally serialize these fields; incomplete responses could produce wrong toggle/scope behavior or undefined collection access
Key Vault and model metadata Missing model context maps, display names, variant flags, and account metadata receive defaults Current Rust structs normally serialize the fields; compatibility fallbacks advertised by the schemas are not real at the RPC boundary
Cursor native models Missing aliases and maxMode receive defaults Current backend normally supplies them; partial responses do not receive the declared fallback

A repository scan currently finds 237 procedure output declarations across 21 RPC procedure files. Most schemas are validation-only and will work as long as Rust returns exactly the expected shape. They are not all known bugs, but they all inherit the same runtime-contract limitation.

Additional Contract Risks

  1. Runtime/type mismatch

    Callers compile against z.output<TOutput>, even though the actual value may still have the wire shape.

  2. Development and production do not enforce the same contract

    Development performs a warning-only parse. Production does not parse output.

  3. Successful Zod sanitization is discarded

    Standard z.object(...) parsing normally returns the parsed object according to its schema. Returning the raw value means unexpected backend fields can remain present at runtime even when the TypeScript type omits them. No confirmed data exposure has been identified, but the schema is not acting as a runtime sanitization boundary.

  4. Schema-only tests can provide false confidence

    Current tests prove that direct schema parsing applies category conversion and legacy event normalization. They do not prove that calling the procedure through typedInvoke returns the parsed value.

Relevant Areas

  • src/api/tauri/rpc/invoke.ts
  • src/api/tauri/rpc/__tests__/router.test.ts
  • src/api/tauri/rpc/schemas/sessionAggregate.ts
  • src/api/tauri/rpc/schemas/sessionCore.ts
  • src/api/tauri/rpc/schemas/mcp.ts
  • src/api/tauri/rpc/schemas/validationValueObjects.ts
  • src/api/tauri/rpc/schemas/validationProcedures.ts
  • src/api/tauri/session/index.ts

Suggested Resolution

First make the RPC contract explicit, then implement one consistent model:

Option A: parsed output is the contract

  • Parse output in every build.
  • Return parsed.data.
  • Define a clear failure policy for invalid responses.
  • Measure the cost on high-frequency RPC paths before landing globally.

Option B: schemas validate only; normalization is explicit

  • Keep production output validation optional if required for performance.
  • Prohibit Zod output transforms, preprocessors, and defaults.
  • Move required wire-to-frontend normalization into an explicit procedure-level mapper that runs in every build.
  • Type the procedure return value from the mapper's output rather than from a schema transformation that is never returned.

Avoid returning parsed.data only in development, because that would make development and production return different semantic values.

The narrow #572 session normalization should remain until the shared contract is fixed and the session procedures have been migrated and verified.

Acceptance Criteria

  • A typedInvoke integration test proves that an output transformation affects the actual returned runtime value.
  • Equivalent integration coverage exists for an output default and an output preprocessor, or those features are explicitly rejected for RPC output schemas.
  • Development and production return the same semantic output shape.
  • The invalid-output policy is explicit and covered by tests.
  • Existing explicit procedure .transform(...) calls, including snake-case-to-camel-case mappings, continue to work.
  • session_aggregate_list and session_native_sidebar_page return canonical frontend categories to their callers without relying on an inaccurate TypeScript assertion.
  • All current mutation-capable output schemas are audited and either migrated or documented as intentionally validation-only.
  • Schema-only tests are supplemented with at least one full mocked-Tauri RPC test for each supported normalization mechanism.
  • Common/high-frequency RPC paths are measured before enabling global production parsing, if Option A is selected.
  • The temporary session-boundary workaround can be removed only after equivalent shared-layer behavior is verified.
  • npm run typecheck and focused RPC/session/MCP/Key Vault tests pass.

Environment

Additional Context

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions