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 |
automationId → legacyAutomationId field rename |
agent-run.ts:589 |
status: waiting_permission → waiting_for_user |
session-store.ts:1068 |
permissionMode: execute → ask |
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.
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:agent-run.ts:583automationId→legacyAutomationIdfield renameagent-run.ts:589status: waiting_permission→waiting_for_usersession-store.ts:1068permissionMode: execute→asktool-result-record-schema.ts:209scheduled-task.tsThe last two were added in #3396, and one of them was missed on the first pass precisely because of this:
hasValidSubagentResultFieldsreads as a validator but servesdecodeCanonicalToolResultContent, 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:
decodePersisted*naming contract, enforced by lint. Cheap, still relies on the author reaching for the right helper.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
mainby the human contributor before filing.