Skip to content

refactor(core): retired-value folding is re-implemented per decoder with nothing enforcing it #3413

Description

@Astro-Han

Problem

Decoding a persisted record and validating fresh input are different jobs with opposite requirements. A persistence decoder must fold retired representations — rejecting one makes an old record unreadable. A wire or input validator must reject them — accepting one masks a handshake that should have been refused.

The codebase expresses both with the same isX() predicates, so which job a call site is doing is carried only by naming and comments. Every decoder therefore hand-writes its own fold, and nothing fails when one forgets. The pattern has now been independently re-implemented five times:

Site Retired representation folded
agent-run.ts:583 automationIdlegacyAutomationId field rename
agent-run.ts:589 status: waiting_permissionwaiting_for_user
session-store.ts:1068 permissionMode: executeask
tool-result-record-schema.ts:209 same, subagent tool results
scheduled-task.ts same, stored Automations

The last two were added in #3396, and one of them was missed on the first pass precisely because of this: hasValidSubagentResultFields reads as a validator but serves decodeCanonicalToolResultContent, a persistence decoder. The classification signal is in the function name; the authority is in the call chain; they disagreed.

This is not specific to PermissionMode. Any closed enum — status, provider, backend kind — pays the same cost on its next contraction, and pays it silently.

Proposed change

Make "this is a persistence read" checkable rather than conventional. The open question is how far to take it:

  • Minimal: one retired-value registry per enum plus a decodePersisted* naming contract, enforced by lint. Cheap, still relies on the author reaching for the right helper.
  • Structural: store read paths return a distinct type that cannot become the domain type without passing a decoder, so omitting the fold fails to compile. Correct, but touches every store read signature.

Pick one deliberately. The minimal option does not close the failure mode that produced this issue.

Why separate

This touches no permission behavior and spans several enums. Reverting it would leave #3396 correct, and reverting #3396 would leave this correct — different revert units, different reviewers.

AI use

Generative tooling made a substantive contribution: Claude Code identified this while implementing #3396, after review caught the missed fold. Evidence verified against current main by the human contributor before filing.

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