feat(ipc): validate host payloads with Zod schemas#28
Merged
chrystiamjr merged 1 commit intoJul 25, 2026
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Every inbound IPC channel did an unchecked cast:
JSON.parsereturnsanyand 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.cs→EvalJs). The only guard in the whole file wasinfo && info.version.If
TaskInfo,BackupInfoor 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.mdexists to prevent, and one this codebase has already hit twice (the localizedStatecolumn, the culture-formatted sizes).What changed
src/domain/schemas.ts— each shape defined once as a Zod schema, with the types intypes.tsinferred from it viaz.inferand re-exported. A schema and its interface can no longer drift.bridge.ts— casts replaced withsafeParse. 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.AppSettings.languageisz.enum(['pt_BR','en_US'])because an unknown locale leaves the UI untranslated, whileScheduledTaskItem.Statestaysz.string()sincedescribeTaskStatealready 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', andJSON.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
Per
.agents/rules/falsifiable-test-guards.md, every one of the 13 schema fields was individually probed by relaxing it toz.any()and confirming a test fails. That surfaced four real coverage gaps in my own first draft —Size,Description,Pathandurlwere only covered by "field missing" cases, whichz.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