Status: not implemented. This document is the plan the Rust core is written against, and the reference for what each capability actually costs. Last updated: 2026-08-28
Every entity, who owns it, and when it comes into existence. Nothing here should be created before something needs it.
| Entity | Owned by | Version | What it is |
|---|---|---|---|
| Process | processes |
V1 | A Windows process the user owns. Identity is pid + createdAt. |
| Endpoint | ports |
V1 | One listening socket: protocol, address family, local address, port. |
| Service | services |
V1 | A Process joined to the Endpoints it owns. |
| Snapshot | sampler |
V1 | One tick: services, processes, ports, conflicts, capturedAt. |
| ProcessDetail | processes |
V1 | Tier-2 fields, fetched on demand. |
| Resource | resources |
V2 | A time series per process, bounded. |
| Project | projects |
V2 | Several Services sharing a working directory or repository. |
| ServiceInstance | services |
V2 | A Service plus enough runtime to reproduce it. |
| Event | events |
V2 | A state change derived from snapshot diffs. |
| Log | logs |
V2.2 | A bounded stream from a process LocalDocks started. |
| Provider | providers |
V3 | A non-Windows source of services: Docker, WSL, infrastructure. |
This is the long-term shape everything is designed toward:
PROCESS ──owns──► ENDPOINT
│
└──is a──► SERVICE ──belongs to──► PROJECT
│
┌──────────────┼──────────────┐
│ │ │
Windows service Docker WSL / infra
container service
V3 providers join at the Service level, not the Process level. A Docker container is not a Windows process, but it is a service belonging to a project. Designing the join there is what stops V3 from requiring a rewrite.
Conceptual. Do not create these directories in advance.
The shape it actually took, as of the dashboard milestone:
src-tauri/src/
├── main.rs
├── lib.rs wiring only: builder, state, handlers, shutdown
├── commands.rs the five IPC handlers — thin, no logic
├── sampler.rs cadence, state, orchestration
├── logic/ pure, syscall-free, unit-tested
│ ├── classify.rs registry + observable data -> one verdict
│ ├── cpu.rs delta maths
│ ├── identity.rs parsing `{pid}-{startedAt}`
│ ├── ports.rs address presentation, PID attribution
│ ├── process.rs raw processes -> ProcessRow
│ ├── registry.rs the Developer Registry — the only file that names a program
│ ├── service.rs the Process + Endpoint[] join
│ ├── telemetry.rs machine CPU deltas, memory arithmetic
│ └── url.rs the open_external allowlist
├── platform/windows/ every `use windows::…`, behind #[cfg]
│ ├── control.rs detail, terminate, ShellExecuteW, command lines
│ ├── gpu.rs PDH engine and memory counters, DXGI identity
│ ├── network.rs GetIfTable2
│ ├── pdh.rs the shared PDH query wrapper
│ ├── ports.rs the four extended-table calls
│ ├── process.rs Toolhelp + OpenProcess
│ ├── storage.rs IOCTL_DISK_PERFORMANCE per physical drive
│ ├── system.rs GetSystemTimes, per-core, GlobalMemoryStatusEx
│ └── thermal.rs PDH ACPI thermal zones
├── models.rs serde types shared with TypeScript
└── errors.rs
models and errors stayed single files rather than becoming directories,
because nothing needed splitting. commands/ likewise: the five handlers are
forty lines together. The rule that modules appear when something fills them
applies to this document too.
Two rules that matter more than the layout:
platform/windows/is the only place Win32 is called. Declare the crate as[target.'cfg(windows)'.dependencies]from the first commit. The retrofit cost oncewindows::imports are scattered across a dozen files is a miserable afternoon; the cost today is one line.logic/never calls a syscall. Endpoint grouping, CPU deltas, the service predicate, dual-stack detection, developer classification, telemetry arithmetic, conflict detection — all plain functions over plain data. This is where the bugs are and where the tests go.registry.rsis the only file that names a program. Every executable name and command-line token in the codebase lives there, versioned. Scattering name checks through the UI or the screens is how four screens start disagreeing about what a developer service is.
| Need | API | Status | Notes |
|---|---|---|---|
| Enumerate | CreateToolhelp32Snapshot + Process32First/Next |
IMPLEMENTED | Simplest. EnumProcesses is an alternative; NtQuerySystemInformation is faster but undocumented. |
| Open | OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) |
IMPLEMENTED | Least privilege that works. Fails with ERROR_ACCESS_DENIED on other accounts — that is a denied field, not an error. Measured: 215 of 370 processes openable unelevated, and every one of them owned by the current user, which is why this doubles as the ownership predicate. |
| Times | GetProcessTimes |
IMPLEMENTED | Creation, exit, kernel, user as FILETIME. Source of both uptime and the identity discriminator, and of the cumulative CPU time the sampler differences. |
| Memory | GetProcessMemoryInfo |
IMPLEMENTED | WorkingSetSize is what Task Manager shows. |
| Image path | QueryFullProcessImageNameW |
IMPLEMENTED | Tier 2, on panel open. |
| Threads | from the Toolhelp snapshot | IMPLEMENTED | Free — already enumerated. |
CPU percentage is computed, not read:
cpu% = Δ(kernel + user) / Δ(wall clock) / core_count
Requires the previous sample. This is the whole reason the sampler holds state.
| Need | API | Notes |
|---|---|---|
| TCP IPv4 | GetExtendedTcpTable(TCP_TABLE_OWNER_PID_LISTENER, AF_INET) |
Gives port→PID directly. |
| TCP IPv6 | same, AF_INET6 |
Separate call. Forgetting this is how half a dev server goes missing. |
| UDP | GetExtendedUdpTable |
V1 where practical; no connection state. |
Both calls follow the query-size-then-allocate pattern and can return
ERROR_INSUFFICIENT_BUFFER when the table grows between the two calls — retry,
do not panic.
| Need | API |
|---|---|
| Verify | OpenProcess → GetProcessTimes → compare creation time |
| Terminate | TerminateProcess |
Verification is not optional. See ARCHITECTURE.md § 3.
Command line is the field V2 project detection depends on, and Windows makes it genuinely unpleasant:
| Approach | Verdict |
|---|---|
WMI Win32_Process.CommandLine |
Works, documented — but COM, and ~100–500 ms for a full query |
NtQueryInformationProcess + read the PEB |
Fast, but undocumented internals and bitness-fragile |
NtQueryInformationProcess(ProcessCommandLineInformation) |
Win10 1511+, much saner, still semi-documented |
Recommendation: none of these in the scan loop, ever. Fetch on detail-panel open.
Amended for the Developer classifier — IMPLEMENTED. The classifier cannot work without command lines: a general-purpose runtime's name proves nothing about what it is running, and the required reasons ("launched with the Vite signature") cannot exist without the line that carries the token. So a bounded read is now in the tick, and the bounds are what keep the original rule's intent:
| Bound | Effect |
|---|---|
| Services only | ~28 candidates, not ~400 |
| Only general-purpose runtimes | 1 of 409 processes on the measured machine — a dedicated program is decided by its name, an excluded one is already refused |
| Cached by process identity | Read once per process lifetime, not once per tick; failures cached too |
| Pruned each tick | The cache is bounded by what is running, not by uptime |
Through open_verified |
A recycled PID yields nothing, never another process's command line — and so never another process's classification |
Decided. Command line uses ProcessCommandLineInformation (class 60) —
IMPLEMENTED. It needs no PROCESS_VM_READ, no ReadProcessMemory and is
not bitness-fragile, which is what ruled out the PEB walk. The size is queried
first rather than guessed, so a long command line is never silently truncated.
Working directory is DEFERRED. There is no route to it but the PEB walk this
table already rates as fragile, and it needs a wider handle than anything else
in the app. It renders as FieldState::Unavailable — an honest absence rather
than a blank that reads like an answer. Revisit when V2 project detection
forces the issue, exactly as this section originally advised.
| Need | API | Status |
|---|---|---|
| Machine CPU | GetSystemTimes |
IMPLEMENTED |
| Per logical processor | NtQuerySystemInformation(SystemProcessorPerformanceInformation), class 8 |
IMPLEMENTED |
| Physical memory | GlobalMemoryStatusEx |
IMPLEMENTED |
All three are counters or levels the kernel already maintains: no handle, no
privilege, one call each. The class-8 struct is declared locally because the
windows crate does not expose it; only the first three fields are read.
The trap worth naming: kernel time already includes idle time. The busy
share is (kernel + user − idle) / (kernel + user). Subtracting idle from the
denominator as well produces numbers that look plausible and are wrong, so
logic::telemetry has a test for exactly that.
Every reading is independently optional. A failed CPU query must not blank the memory figures, and neither may take a tick down — telemetry is decoration on a process dashboard. ROADMAP.md records what is deferred and why; none of it is fabricated to fill a slot.
All four were chosen after measuring the candidates unelevated on a real machine, not from documentation alone.
| Need | API / provider | Elevation | Measured cost | Status |
|---|---|---|---|---|
| Network throughput | GetIfTable2, MIB_IF_ROW2.InOctets / OutOctets |
none | 0.90 ms, 50 interfaces | IMPLEMENTED |
| Storage throughput and active time | DeviceIoControl(IOCTL_DISK_PERFORMANCE) on \\.\PhysicalDriveN |
none | 0.11 ms per drive, 0.05 ms to sweep 0–15 | IMPLEMENTED |
| GPU utilisation | PDH \GPU Engine(*)\Utilization Percentage |
none | 0.50 ms, 599 instances | IMPLEMENTED |
| GPU memory | PDH \GPU Adapter Memory(*)\Dedicated Usage / Shared Usage |
none | 0.008 ms | IMPLEMENTED |
| GPU identity | DXGI EnumAdapters1 → DXGI_ADAPTER_DESC1 |
none | once, cached | IMPLEMENTED |
| ACPI thermal zones | PDH \Thermal Zone Information(*)\Temperature |
none | 0.09 ms, 3 zones | IMPLEMENTED |
Rejected, with the reason each was rejected:
| Candidate | Why not |
|---|---|
WMI MSAcpi_ThermalZoneTemperature |
Measured returning access denied unelevated. The obvious route to a temperature, and it needs administrator rights |
D3DKMTQueryStatistics |
Undocumented gdi32 internals |
| NVML, AMD ADL | Vendor SDKs, and the development machine has one adapter of each vendor |
IDXGIAdapter3::QueryVideoMemoryInfo |
Reports the calling process's memory budget, so it would show LocalDocks' own usage as the GPU's |
GetProcessIoCounters for disk |
Counts file, network and device I/O in one number |
ETW Microsoft-Windows-Kernel-Network |
Starting a session needs elevation |
PDH \PhysicalDisk counters |
Would work, but returns rates already differenced rather than the cumulative counters the delta model uses, costs a held-open query, and carries the counter-name localisation problem |
Four things that are easy to get wrong, each with a test:
PdhAddEnglishCounterW, notPdhAddCounterW. Counter names are localised. A hard-coded\GPU Engine(*)\Utilization Percentagefails on a German or Japanese Windows through the localised call.- A zero-access handle opens a physical drive.
CreateFileWwith a desired access of0can issueIOCTL_DISK_PERFORMANCEbut cannot read a byte of the device, which is exactly why an unelevated user is permitted to open it. Asking forGENERIC_READwould demand administrator rights. - Disk active time comes from idle time.
ReadTime + WriteTimeexceeds the elapsed time on any device that services requests concurrently — every NVMe drive made this decade — which is why Windows' own% Disk Timereports several hundred percent.DISK_PERFORMANCE's time fields are in 100 ns units, which the reference page does not state, so a test asserts it against a measured interval rather than assuming it. - GPU engines are summed within a type and maximised across types. 3D, Copy and Video Decode are separate hardware queues that run concurrently, so adding them reports well over 100% for a machine doing one thing.
| Need | API | Status |
|---|---|---|
| Verify before acting | OpenProcess → GetProcessTimes → compare creation time |
IMPLEMENTED |
| Terminate | TerminateProcess with PROCESS_TERMINATE |
IMPLEMENTED |
| Open a URL | ShellExecuteW, after an http/https allowlist check |
IMPLEMENTED |
open_external adds no dependency: the URL is validated in logic::url — a
pure function with its own tests — and handed to the OS's own "open this the
way the user would" verb. No shell is invoked and no argument string is built.
An allowlist rather than a blocklist, because any installed application can
register a new scheme at any time.
Honest estimates, so scope decisions are made with the price visible.
| Capability | Cost | The hard part |
|---|---|---|
| Conflict detection | Low | Pure logic over data V1 already has. Snapshot.conflicts is already number | null, awaiting a real number. |
| Endpoint grouping / exposure | Low | Logic only. Distinguishing 127.0.0.1 from 0.0.0.0 is a rendering and modelling decision. |
| Port availability | Low | Attempt a bind, or diff against the table. |
| Resource history | Medium | A bounded ring buffer per process. Memory growth must be capped, and dead processes evicted. |
| Tray / background | Medium | Tauri lifecycle, not Win32. Idle footprint is the real constraint. |
| Events / notifications | Medium | Snapshot diffing. Design the event model once — V3 depends on it. |
| Project detection | High | Depends entirely on tier-2 data. Filesystem walking up from cwd, Git discovery, manifest parsing. No new Win32. |
| Service lifecycle | High | Reproducing a launch. Command line, cwd and environment. CreateProcessW with correct handle inheritance. |
| Logs | High | Only possible for processes LocalDocks started. Windows offers no supported way to attach to a running process's stdout. Blocked on lifecycle. |
Capturing a process environment to enable restart is the sharpest security edge in V2. A dev server's environment routinely contains database URLs, API keys and tokens. Rules, decided now:
- Never log it
- Never persist it unencrypted
- Never display it in full without explicit user action
- Prefer re-deriving from a project definition over capturing from a live process
Recorded here because these are backend facts, verified on a packaged build
rather than in cargo test.
| Property | State |
|---|---|
| Runs elevated | Never. No manifest requests it, and the installer is per-user |
SeDebugPrivilege |
Never enabled. Nothing in the codebase calls AdjustTokenPrivileges |
| Widest handle requested | PROCESS_QUERY_LIMITED_INFORMATION, plus PROCESS_TERMINATE for one operation |
| Raw device access | CreateFileW with desired access 0 — enough for one IOCTL, not enough to read a byte |
| Tauri capabilities | core:default only. No filesystem, shell, http or dialog plugin |
Running unelevated is a limit as well as a promise: LocalDocks can only see processes the current user owns. That is why the service model can use "has a row" as the ownership test without a token lookup — measured on a development machine as 215 openable processes, 214 owned by the user.
Two expect calls exist in production code and no others. Both are at startup,
before any window appears, and both are unrecoverable by construction: the Tauri
application failing to build, and the OS refusing to start the sampler thread.
There is no unwrap, panic!, todo!, unimplemented!, dbg! or println!
on any path a command can reach.
Release writes Warn and above to one rotating 512 KB file in
%LOCALAPPDATA%\com.silentminds.localdocks\logs. Debug writes Info to stdout
and the webview console.
Executable names, PIDs, port numbers and Windows error text can reach the file. Command lines, file paths and working directories cannot — they are read for the detail panel and the classifier, and neither logs at warn or above.
None of it is transmitted. The only network request LocalDocks makes is the
update check (docs/UPDATES.md), which is a GET carrying no body and no
identifier — nothing observed here can reach it.
System calls fail routinely and most failures are normal, not exceptional. Modelling them as ordinary outcomes is what keeps the UI honest.
enum LocalDocksError {
ProcessGone, // exited between enumeration and query — routine
AccessDenied, // another account. Renders "Requires elevation"
IdentityMismatch, // PID recycled. A refusal, not a failure
InvalidPid,
ApiFailure { code: u32, call: &'static str },
Unsupported, // this Windows build lacks the API
}Rules:
- No
unwrap/expecton any path reachable from a command. A process exiting mid-scan is expected behaviour, not a panic. AccessDeniedis a value, not an error. It becomesFieldState::Deniedand renders as "Requires elevation".IdentityMismatchis a success path for the safety model working.- API failures carry the call name —
GetExtendedTcpTable failed: 122is debuggable; "scan failed" is not.
| Layer | How |
|---|---|
logic/ |
Rust unit tests. Pure functions, no mocking needed. The highest-value tests in the project. |
platform/windows/ |
Integration tests against real processes; assert shape and invariants, not exact values. |
commands/ |
Thin by design — if a command needs heavy testing, logic leaked into it. |
| Frontend pure logic | Vitest. 38 tests today over format, detail and settings. |
Do not build a ProcessSource trait to enable mocking. Separating pure
logic from syscalls already gives testability without the abstraction. The trait
becomes justified when a second platform is real — not before.
1. feature/project-setup Tauri foundation, IPC round-trip DONE
2. feature/process-discovery Win32 enumeration, plain command DONE
3. feature/sampler Managed state, CPU deltas, events DONE
4. feature/port-discovery GetExtendedTcpTable, v4 + v6, and UDP DONE
5. feature/service-model Join by PID, the service predicate DONE
6. feature/dashboard Wire the existing UI to real data DONE
7. feature/process-actions Verified terminate, open, copy
Step 3 must not be deferred past step 4: CPU percentage is impossible without it, and retrofitting the sampler after the port code exists means rewriting both.