Skip to content

feat(ipc): validate host payloads with Zod schemas#28

Merged
chrystiamjr merged 1 commit into
fix/list-refresh-dark-controlsfrom
feat/validate-ipc-payloads
Jul 25, 2026
Merged

feat(ipc): validate host payloads with Zod schemas#28
chrystiamjr merged 1 commit into
fix/list-refresh-dark-controlsfrom
feat/validate-ipc-payloads

Conversation

@chrystiamjr

Copy link
Copy Markdown
Owner

Stacked on #27 (fix/list-refresh-dark-controls) — merge that one first.

Problem

Every inbound IPC channel did an unchecked cast:

const data: ScheduledTaskItem[] = typeof tasksJson === 'string' ? JSON.parse(tasksJson) : tasksJson;

JSON.parse returns any and TypeScript types are erased at runtime, so this is a promise the compiler cannot keep. The host serializes its own records and injects them straight into the page (MainWindow.xaml.csEvalJs). The only guard in the whole file was info && info.version.

If TaskInfo, BackupInfo or the restore-point shape changed on the C# side, nothing failed loudly — the table rendered wrong or blank with no diagnostic. This is the failure class .agents/rules/fullstack-sync.md exists to prevent, and one this codebase has already hit twice (the localized State column, the culture-formatted sizes).

What changed

  • src/domain/schemas.ts — each shape defined once as a Zod schema, with the types in types.ts inferred from it via z.infer and re-exported. A schema and its interface can no longer drift.
  • bridge.ts — casts replaced with safeParse. Rejected payloads log which fields failed. Collections fall back to []; settings keep app defaults instead of receiving an empty object that would blank every preference.
  • Objects are non-strict, so a field the host adds later is stripped rather than rejected.
  • Deliberate strictness calls: AppSettings.language is z.enum(['pt_BR','en_US']) because an unknown locale leaves the UI untranslated, while ScheduledTaskItem.State stays z.string() since describeTaskState already handles unknown values.

One implementation trap worth noting

JSON unwrapping is a separate helper from plain validation. An earlier version unwrapped every string, which broke the log channel: the severity arrives as a bare 'success', and JSON.parse('success') throws. The bridge test caught it.

On React Hook Form

Considered and rejected. The app's only form has closed-set fields (you cannot select an invalid <select> option), no free-text inputs anywhere, no cross-field rules beyond conditional rendering, no async validation and no error state to render. A resolver would add a dependency and indirection with no rule to enforce. The real unguarded boundary was the IPC layer, which is what this PR addresses. Worth revisiting if a form with free text or cross-field rules appears.

Verification

npm run validate    22 files, 164 tests green (from 132)
npx cypress run     80 e2e green
npm run build       clean

Per .agents/rules/falsifiable-test-guards.md, every one of the 13 schema fields was individually probed by relaxing it to z.any() and confirming a test fails. That surfaced four real coverage gaps in my own first draft — Size, Description, Path and url were only covered by "field missing" cases, which z.any() accepts, so the type was never actually asserted. Per-field wrong-type cases were added and all 13 now fail when relaxed.

The e2e suite gained the case that motivated the change: a well-formed payload of the wrong shape, which previously sailed through the cast and only surfaced as a wrong table.

Manual check

Open the app and confirm all three lists still load. A [IPC Bridge] console error during normal use would mean a schema is wrong, not the host.

🤖 Generated with Claude Code

Every inbound channel did an unchecked cast: `JSON.parse(...) as ScheduledTaskItem[]`
is a promise the compiler cannot keep, since JSON.parse returns any and TS types
are erased at runtime. The host serializes its own records and injects them via
EvalJs, so a shape change on the C# side surfaced as a blank or wrong table with
no diagnostic — the failure class fullstack-sync exists to prevent, and one this
codebase already hit twice.

Define each shape once in domain/schemas.ts and infer the types from it, so a
schema and its interface cannot drift. Replace the casts with safeParse: rejected
payloads now log which fields failed. Collections fall back to an empty list;
settings keep app defaults rather than blanking every preference.

JSON unwrapping is a separate helper from plain validation. Applying it to every
string would break channels whose payload is legitimately a bare string — the log
severity, where JSON.parse('success') throws.

React Hook Form was considered and rejected: the only form has closed-set fields,
no free text, no cross-field rules and no async validation, so a resolver would
add indirection without a rule to enforce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chrystiamjr
chrystiamjr merged commit 7d009ec into fix/list-refresh-dark-controls Jul 25, 2026
@chrystiamjr
chrystiamjr deleted the feat/validate-ipc-payloads branch July 25, 2026 04:31
chrystiamjr added a commit that referenced this pull request Jul 25, 2026
…loads (#27)

Fixes everything reported while testing 0.1.4 in the real app. The
multi-schedule feature itself was confirmed working — two Quick/Daily
tasks at 03:00 and 05:00 coexist.

## Stale lists

A task deleted in `taskschd.msc` stayed on screen. Root cause:
`src/App.tsx` dispatched the `GET_*` actions **only on mount** — no
polling, no refetch on navigation, none after a mutation. The data is
always read live from Windows (there is no sidecar index), but the UI
never asked again.

- `DataTable` gains an optional refresh control, so all three management
screens get it at once.
- Each screen re-queries its own data set on entry. Settings is
deliberately excluded: it holds unsaved local edits a refetch would
clobber.
- Refresh is a read, so it uses `handleAction` and does **not** take the
action lock — but it is disabled while a mutation is in flight.

Two placement details that are load-bearing:

- The control sits **above** the scroll wrapper. Inside it, scrolling a
narrow table sideways would carry the button out of view.
- It renders **outside** the empty-state branch. An empty list is
precisely when the host needs re-querying — deleting the last task in
Windows leaves nothing to hang a control off.

The refetch is deliberately **not** hooked to `subscribeActionFinished`:
the host emits `onActionFinished` for every action including the `GET_*`
reads themselves (`MainWindow.xaml.cs` `finally` block), which would
loop forever.

## Native controls painted light

The clock indicator on the time field rendered black and `<select>`
popups opened white on dark fields. The app never declared
`color-scheme`, and WebView2 is Chromium, so it painted every native
control in its light default. Declared dark in `index.css` and in the
document head so the first paint is already correct.

With that fixed the native time picker opens dark and legible, so it is
kept rather than replaced.

## Atomic Design gaps

`Select` and `FormField` are documented in `AGENTS.md` but did not
exist. The label pattern was repeated 5× in `SchedulingPage` and
`selectClass` was a loose const, so the `appearance-none` + inset
chevron fix would have been copy-pasted across four controls. Extracted
both.

The 31-option day-of-month `<select>` is replaced by a calendar grid —
the one control where the native element is genuinely worse than a
custom one. It is a `radiogroup`, which is not labelable, so it takes
its accessible name from `aria-label` rather than `<label for>`.

## Design token violations

`Badge` defaulted `text` to the hardcoded Portuguese `'Administrador
Ativo'` — dead code that bypassed i18n. Now required. Both `Badge` and
`Dot` used the raw Tailwind palette (`emerald`/`amber`/`rose`/`sky`) and
hardcoded hex glows instead of the design tokens, against the explicit
rule in `AGENTS.md`. The glows are now `boxShadow` tokens. **Expected
visual change:** the "Pronto" badge moves from `emerald-400` to the
project's `success` green, matching the Schedule button.

## Found while testing

- `React.StrictMode` double-invokes effects in dev, so bootstrap `GET_*`
counts are doubled locally. Absolute IPC call counts are therefore not
assertable — the new tests compare before/after deltas.
- The i18n context recreated `setLang` on every render, so the bootstrap
effect (which lists it as a dependency) re-ran and re-requested every
data set whenever the language changed. Memoized. This was real in
production, independent of StrictMode.
- Cypress run artifacts (`cypress/screenshots/`) were untracked and
ungitignored. Added.

## Verification

```
npm run validate    21 files, 132 tests green (from 108)
npx cypress run     78 e2e green (from 70)
npm run build       clean
dotnet build        clean
```

Both new guards were proven falsifiable: emptying
`SCREEN_REFRESH_ACTIONS[Scheduling]` fails the navigation test, and
hiding the refresh control fails 4 DataTable tests.

## Manual check

Reproduce the original report: schedule two tasks, delete one in
`taskschd.msc`, click **Atualizar** → the row disappears; leaving and
re-entering the screen does it without a click. Then confirm the
chevrons are inset, the clock icon is white, the time picker opens dark,
and the monthly grid produces `day: "15"`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---

# Also in this PR

## IPC payload validation (was #28, merged into this branch)

Every inbound channel did an unchecked cast — `JSON.parse` returns `any`
and TS types are erased at runtime, so the host could change a shape and
the table would render blank with no diagnostic. Each shape is now a Zod
schema in `src/domain/schemas.ts`, with the interfaces in `types.ts`
inferred from it, and `bridge.ts` validates with `safeParse` and logs
the failing fields. Collections fall back to `[]`; settings keep app
defaults.

React Hook Form was considered and declined: the only form has
closed-set fields with nothing to validate. The full rationale is in
learning record 0003.

All 13 schema fields were individually probed by relaxing each to
`z.any()`. That surfaced four fields (`Size`, `Description`, `Path`,
`url`) that only had "field missing" cases — which `z.any()` accepts, so
the type was never asserted. Fixed.

## Learned rules

Three artifacts capturing what this work taught, indexed in `AGENTS.md`
(now 11 rules):

- **`os-backed-list-freshness.md`** — the stale-list report had an
architectural cause, plus the two traps hit while fixing it:
`onActionFinished` fires for the `GET_*` reads themselves so refetching
there loops, and a refresh is a read that must not take the action lock.
- **`webview2-native-control-theming.md`** — records that the black
clock icon and white select popup shared one cause, to prevent the wrong
conclusion that was nearly drawn: replacing a native picker that only
needed theming.
- **`falsifiable-test-guards.md`** (extended) — per-field probing, why a
"field missing" case does not test that field's type, and why StrictMode
makes absolute IPC call counts unassertable.

## Combined verification

```
npm run validate    164 unit tests green (from 108)
npx cypress run     80 e2e green (from 70)
npm run build       clean
dotnet build        clean
```

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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