Background
The repository currently maintains three overlapping types for filesystem paths in packages/shared/file/types/common.ts and packages/shared/data/types/file/fileEntry.ts:
| Type |
Definition |
What it guarantees |
FilePath |
Template literal: `/${string}` | `${string}:\\${string}` |
Pattern only — TypeScript hint, no runtime check |
CanonicalExternalPath |
Phantom brand: string & { [brand]: 'CanonicalExternalPath' } |
Reached via canonicalizeExternalPath() (NFC + resolve + strip sep + reject null bytes) |
CanonicalFilePath |
FilePath & CanonicalExternalPath |
Both above |
canonicalizeExternalPath does four things, all pure JS, no filesystem IO:
- Reject null bytes
path.resolve(raw) — make absolute, collapse ./ ../
- Unicode NFC normalize
- Strip trailing separator
The "main-only" framing in current JSDoc (packages/shared/file/types/ipc.ts:117-152) was about the call site (renderer has no need to canonicalize before sending to IPC), not about the function itself.
There is already a TODO in the codebase recognizing the overlap:
// packages/shared/data/types/file/fileEntry.ts:128
// TODO: AbsolutePathSchema and FilePath (@shared/file/types/common) are semantically
// identical — co-locate them in a single file and derive one from the other.
This issue acts on that TODO.
Problem
The FilePath template literal type is a lie of omission. It promises only "looks like an absolute path"; it does not promise NFC, does not reject null bytes, does not strip trailing separators. A FilePath value can carry every form of latent inconsistency that breaks string equality:
- Native dialogs on macOS APFS return NFD-decomposed paths. The same visual
café from a café-form dialog return will fail === against an NFC-form café literal
- Renderer code uses
Array.includes(path) / currentDirectories.some(d => d.path === selected) for dedup against in-state paths (see AgentModal:226, AccessibleDirsSetting:37, BasicSection:83, AddKnowledgeItemDialog:83)
- Without NFC, every such call is a silent ghost-duplicate waiting to happen
Meanwhile CanonicalExternalPath already provides exactly the brand we need, but it sits behind a name and a JSDoc that frame it as "DB-write-side only." The renderer ends up with the pattern-only FilePath and is on its own for normalization.
The asymmetry has no design justification anymore. Once canonicalizeExternalPath is recognized as a pure function (not main-only), the brand can live everywhere paths live.
Separately, the current AbsolutePathSchema JSDoc claims "Zod cannot enforce this shape at the schema level" — this is not accurate. A Zod transform can absolutely perform NFC + resolve + strip; the schema was simply never written to do so.
Proposal
Single brand, named FilePath, produced by upgrading the existing AbsolutePathSchema — not a new factory function. The Zod schema is already the project-idiomatic way to validate-and-transform; reusing it keeps the type and its construction discipline in one place.
Rebrand FilePath
// packages/shared/file/types/common.ts
declare const filePathBrand: unique symbol
export type FilePath = string & { readonly [filePathBrand]: 'FilePath' }
The template literal pattern is dropped (it was a weak hint, no real guard). The phantom brand is the only contract.
Rename, relocate, and expand the schema
AbsolutePathSchema → FilePathSchema, moved to packages/shared/file/types/common.ts (alongside the FilePath type it produces). The transform takes on the real normalization work that used to live in canonicalizeExternalPath:
// packages/shared/file/types/common.ts
export const FilePathSchema = z
.string()
.min(1)
.refine((s) => !s.includes('\0'), 'must not contain null bytes')
.refine((s) => s.startsWith('/') || /^[A-Za-z]:\\/.test(s), 'must be absolute')
.transform((v): FilePath => {
const resolved = path.resolve(v)
return resolved.normalize('NFC').replace(/[/\\]+$/, '') as FilePath
})
FilePathSchema.parse(raw) is the only authorized brand entry point. safeParse for callers that want structured error handling.
Type and function removals
FilePath (template literal) — replaced by the new brand definition. Name reused, semantics upgraded
CanonicalExternalPath — completely subsumed by the new FilePath. Delete
CanonicalFilePath — same, delete
canonicalizeExternalPath / canonicalizeAbsolutePath functions — delete. Their work is absorbed into FilePathSchema.transform
Entry point updates
All sites that today produce paths via as FilePath or as CanonicalExternalPath cast must route through FilePathSchema.parse():
- File / folder picker handlers in
FileManager.openSelectDialog / openSaveDialog
- IPC receiver boundaries that accept raw strings from renderer
- DB read paths where
externalPath is fetched as string and re-typed
- String-literal path construction in production code (rare; should be flagged for review)
ensureExternalEntry drops its internal canonicalize step since IPC schema parse on EnsureExternalEntryIpcParams already canonicalizes.
IPC boundary handling
The brand is a phantom — IPC serialization drops it. This is unchanged from current CanonicalExternalPath behavior; the receiver re-asserts via as FilePath at the trusted boundary, backed by the sender's contract.
Scope
Current usage in the codebase:
| Symbol |
Files |
Notes |
FilePath |
105 |
Most are passive consumers — type appears in signatures, values flow through unchanged |
as FilePath cast |
214 |
Each requires audit: legitimate brand entry, or accidental escape hatch |
CanonicalExternalPath |
9 |
Concentrated around file_entry.externalPath write path — small surface |
CanonicalFilePath |
3 |
Recently introduced; full sweep is feasible |
AbsolutePathSchema |
~5 |
Renamed in this PR |
Risks
- IPC brand erasure: same as current
CanonicalExternalPath; no regression
- Test fixtures with literal
as FilePath casts: some literals are NFD or have trailing separators that pass the template-literal check today but would fail the brand factory. Each must be updated to call FilePathSchema.parse()
- Performance:
FilePathSchema.parse() adds NFC normalize + resolve + strip on every cast site. Micro-cost per call; pre-call cache not needed at this scale
path.resolve semantics: resolving against process.cwd() differs between main and renderer. Spec must require absolute input (schema refines reject relative) — keeps semantics process-independent
Open design questions
- Should
FilePathSchema accept FilePath as a no-op pass-through? Useful when receiving values of unknown brand-provenance in untyped contexts. Zod schemas are naturally idempotent on already-normalized inputs (NFC.normalize is a no-op on NFC, trailing-sep strip is a no-op on stripped paths) — but the cost is non-zero. Default: yes, accept (idempotency is natural)
- What about
file:// URLs? Currently rejected by AbsolutePathSchema. Behavior unchanged — FilePathSchema rejects them
- CI lint to forbid
as FilePath outside tests? An oxlint rule could prevent regression. Default: yes, scoped to production code
Discovery context
This issue was surfaced during the legacyFile renderer consumer audit (v2-refactor-temp/docs/file-manager/legacyfile-renderer-consumer-audit.md §5.2) while reviewing selectFolder consumers. The audit found:
selectFolder returns string | null, missing the FilePath type cue (separate, smaller fix already in eurfelux/v2/file-ipc — openSelectDialog / openSaveDialog IPC signatures updated to return FilePath)
- Four consumers (
AgentModal, AccessibleDirsSetting, BasicSection, AddKnowledgeItemDialog) use the dialog return value as a dedup key via Array.includes / .some(d => d.path === ...) — silently broken under NFD on macOS APFS
- Fixing it ad-hoc (NFC helper in the dialog handler) was rejected as a patch; the right fix is at the type level
Affected documents
packages/shared/file/types/common.ts — new FilePath brand definition, host the relocated FilePathSchema
packages/shared/data/types/file/fileEntry.ts — AbsolutePathSchema relocation + CanonicalExternalPath / CanonicalFilePath removal
src/main/services/file/utils/pathResolver.ts — canonicalizeExternalPath / canonicalizeAbsolutePath removal
packages/shared/file/types/ipc.ts:117-152 — JSDoc on EnsureExternalEntryIpcParams (the "asymmetry is deliberate" rationale needs rewriting)
v2-refactor-temp/docs/file-manager/file-arch-problems-response.md — brand layering discussion
v2-refactor-temp/docs/file-manager/rfc-file-manager.md — type contract
Background
The repository currently maintains three overlapping types for filesystem paths in
packages/shared/file/types/common.tsandpackages/shared/data/types/file/fileEntry.ts:FilePath`/${string}` | `${string}:\\${string}`CanonicalExternalPathstring & { [brand]: 'CanonicalExternalPath' }canonicalizeExternalPath()(NFC + resolve + strip sep + reject null bytes)CanonicalFilePathFilePath & CanonicalExternalPathcanonicalizeExternalPathdoes four things, all pure JS, no filesystem IO:path.resolve(raw)— make absolute, collapse./../The "main-only" framing in current JSDoc (
packages/shared/file/types/ipc.ts:117-152) was about the call site (renderer has no need to canonicalize before sending to IPC), not about the function itself.There is already a TODO in the codebase recognizing the overlap:
This issue acts on that TODO.
Problem
The
FilePathtemplate literal type is a lie of omission. It promises only "looks like an absolute path"; it does not promise NFC, does not reject null bytes, does not strip trailing separators. AFilePathvalue can carry every form of latent inconsistency that breaks string equality:caféfrom acafé-form dialog return will fail===against an NFC-formcaféliteralArray.includes(path)/currentDirectories.some(d => d.path === selected)for dedup against in-state paths (seeAgentModal:226,AccessibleDirsSetting:37,BasicSection:83,AddKnowledgeItemDialog:83)Meanwhile
CanonicalExternalPathalready provides exactly the brand we need, but it sits behind a name and a JSDoc that frame it as "DB-write-side only." The renderer ends up with the pattern-onlyFilePathand is on its own for normalization.The asymmetry has no design justification anymore. Once
canonicalizeExternalPathis recognized as a pure function (not main-only), the brand can live everywhere paths live.Separately, the current
AbsolutePathSchemaJSDoc claims "Zod cannot enforce this shape at the schema level" — this is not accurate. A Zodtransformcan absolutely perform NFC + resolve + strip; the schema was simply never written to do so.Proposal
Single brand, named
FilePath, produced by upgrading the existingAbsolutePathSchema— not a new factory function. The Zod schema is already the project-idiomatic way to validate-and-transform; reusing it keeps the type and its construction discipline in one place.Rebrand
FilePathThe template literal pattern is dropped (it was a weak hint, no real guard). The phantom brand is the only contract.
Rename, relocate, and expand the schema
AbsolutePathSchema→FilePathSchema, moved topackages/shared/file/types/common.ts(alongside theFilePathtype it produces). The transform takes on the real normalization work that used to live incanonicalizeExternalPath:FilePathSchema.parse(raw)is the only authorized brand entry point.safeParsefor callers that want structured error handling.Type and function removals
FilePath(template literal) — replaced by the new brand definition. Name reused, semantics upgradedCanonicalExternalPath— completely subsumed by the newFilePath. DeleteCanonicalFilePath— same, deletecanonicalizeExternalPath/canonicalizeAbsolutePathfunctions — delete. Their work is absorbed intoFilePathSchema.transformEntry point updates
All sites that today produce paths via
as FilePathoras CanonicalExternalPathcast must route throughFilePathSchema.parse():FileManager.openSelectDialog/openSaveDialogexternalPathis fetched asstringand re-typedensureExternalEntrydrops its internal canonicalize step since IPC schema parse onEnsureExternalEntryIpcParamsalready canonicalizes.IPC boundary handling
The brand is a phantom — IPC serialization drops it. This is unchanged from current
CanonicalExternalPathbehavior; the receiver re-asserts viaas FilePathat the trusted boundary, backed by the sender's contract.Scope
Current usage in the codebase:
FilePathas FilePathcastCanonicalExternalPathfile_entry.externalPathwrite path — small surfaceCanonicalFilePathAbsolutePathSchemaRisks
CanonicalExternalPath; no regressionas FilePathcasts: some literals are NFD or have trailing separators that pass the template-literal check today but would fail the brand factory. Each must be updated to callFilePathSchema.parse()FilePathSchema.parse()adds NFC normalize + resolve + strip on every cast site. Micro-cost per call; pre-call cache not needed at this scalepath.resolvesemantics: resolving againstprocess.cwd()differs between main and renderer. Spec must require absolute input (schema refines reject relative) — keeps semantics process-independentOpen design questions
FilePathSchemaacceptFilePathas a no-op pass-through? Useful when receiving values of unknown brand-provenance in untyped contexts. Zod schemas are naturally idempotent on already-normalized inputs (NFC.normalize is a no-op on NFC, trailing-sep strip is a no-op on stripped paths) — but the cost is non-zero. Default: yes, accept (idempotency is natural)file://URLs? Currently rejected byAbsolutePathSchema. Behavior unchanged —FilePathSchemarejects themas FilePathoutside tests? An oxlint rule could prevent regression. Default: yes, scoped to production codeDiscovery context
This issue was surfaced during the
legacyFilerenderer consumer audit (v2-refactor-temp/docs/file-manager/legacyfile-renderer-consumer-audit.md §5.2) while reviewingselectFolderconsumers. The audit found:selectFolderreturnsstring | null, missing theFilePathtype cue (separate, smaller fix already ineurfelux/v2/file-ipc—openSelectDialog/openSaveDialogIPC signatures updated to returnFilePath)AgentModal,AccessibleDirsSetting,BasicSection,AddKnowledgeItemDialog) use the dialog return value as a dedup key viaArray.includes/.some(d => d.path === ...)— silently broken under NFD on macOS APFSAffected documents
packages/shared/file/types/common.ts— newFilePathbrand definition, host the relocatedFilePathSchemapackages/shared/data/types/file/fileEntry.ts—AbsolutePathSchemarelocation +CanonicalExternalPath/CanonicalFilePathremovalsrc/main/services/file/utils/pathResolver.ts—canonicalizeExternalPath/canonicalizeAbsolutePathremovalpackages/shared/file/types/ipc.ts:117-152— JSDoc onEnsureExternalEntryIpcParams(the "asymmetry is deliberate" rationale needs rewriting)v2-refactor-temp/docs/file-manager/file-arch-problems-response.md— brand layering discussionv2-refactor-temp/docs/file-manager/rfc-file-manager.md— type contract