# jmap-filenode-types — Implementation Plan
JMAP FileNode extension (draft-ietf-jmap-filenode-13) data types. Types only — no
method handlers, no async, no network I/O. This crate sits between `jmap-types`
(shared JMAP base primitives) and `jmap-filenode-server` / `jmap-filenode-client`.
## Crate Family Position
```
jmap-types (RFC 8620 wire primitives)
└── jmap-filenode-types ← this crate
├── jmap-filenode-server (method handlers)
└── jmap-filenode-client (extension trait)
```
## What This Crate Covers
Each object type maps to one source module. The normative reference for every
field name, type, and serialization rule is draft-ietf-jmap-filenode-13 §3.
| `filenode.rs` | `FileNode`, `NodeType`, `FilesRights` | §3.1 |
| `query.rs` | `FileNodeFilterCondition`, `FileNodeFilter`, `FileNodeComparator` | §3.2.5 |
| `capability.rs` | `FileNodeCapability` (account-level) | §2.1 |
Generic query types (`Filter<T>`, `FilterOperator<T>`, `Operator`) live in
`jmap-types::query` because they are defined by RFC 8620 §5.5, not this draft.
## What Is Out of Scope
- Method handlers (`FileNode/get`, `FileNode/set`, etc.) — those live in `jmap-filenode-server`
- Transport and network I/O — no tokio, no reqwest
- Partial PATCH semantics — `jmap-filenode-server` applies patches; this crate holds the types
- JMAP Blob Extensions (`RFC 9404) integration types (`ArchiveEntry`
extension with `nodeId`/`recurse`, `ExtractRecipe` with `parentNodeId`) — those belong in
a future `jmap-blobext-types` crate that depends on this one
## Full Type Reference (draft-ietf-jmap-filenode-13 §3.1)
### FileNode
The central object. Represents either a file, a directory, or a symlink in a
hierarchical tree rooted at nodes with `parentId: null`.
| `id` | `Id` | immutable; server-set |
| `parentId` | `Id\|null` | null = top-level node |
| `nodeType` | `String` | immutable after creation; server-inferred if absent; see `NodeType` |
| `blobId` | `Id\|null` | non-null for file nodes; null for directory and symlink |
| `target` | `String[]\|null` | symlink target as path element array; null for file/directory |
| `size` | `UnsignedInt\|null` | server-set; null for directory/symlink; set from blob on create/update |
| `name` | `String` | Net-Unicode, min 1 char; siblings under same parent must be unique |
| `type` | `String\|null` | IANA media type; non-null for files, null for directory/symlink |
| `created` | `UTCDate` | default: current server time |
| `modified` | `UTCDate\|null` | client-managed; null → server sets to current time |
| `accessed` | `UTCDate\|null` | client-managed; null → server sets to current time |
| `changed` | `UTCDate` | server-set; auto-updated on any property change |
| `executable` | `Boolean` | default: false |
| `isSubscribed` | `Boolean` | per-user; default: true |
| `myRights` | `FilesRights` | server-set; derived ACL for requesting user, including inheritance |
| `shareWith` | `Id[FilesRights]\|null` | map of userId→rights; null if not shared or user lacks mayShare |
| `role` | `String\|null` | special directory role; null for files; see `FileNodeRole` constants |
Note: the wire field name `type` collides with a Rust keyword. Use
`#[serde(rename = "type")]` on the struct field, which must be named `media_type`
in Rust.
### NodeType
The `nodeType` field takes one of three registered values (IANA "JMAP FileNode Types"
registry, §10.4), plus an open catch-all for forward compatibility:
```
"file" — regular file; blobId must be non-null
"directory" — collection that may contain child nodes; blobId must be null
"symlink" — symbolic link; target must be non-null, blobId must be null
```
Use a custom serde implementation (not `#[serde(rename_all)]`) to support an
`Other(String)` variant. Clients MUST ignore unrecognised nodeType values per the
general JMAP extensibility principle.
### FilesRights
The ACL object returned in `myRights` and used as the value type in `shareWith`.
All fields are `Boolean`.
| `mayRead` | May read properties and blob content |
| `mayAddChildren` | May create child nodes in this directory |
| `mayRename` | May rename or move this node |
| `mayDelete` | May destroy this node |
| `mayModifyContent` | May update blobId, type, target, modified, accessed, executable |
| `mayShare` | May change shareWith (see RFC 9670 JMAP Sharing) |
### FileNodeRole constants
Known roles from the IANA "JMAP FileNode Roles" registry (§10.5):
```
"root" — base of a filesystem; should have parentId null
"home" — user's home directory
"temp" — temporary space; may be cleaned up automatically
"trash" — deleted data
"documents" — document storage
"downloads" — downloaded files
"music" — audio files
"pictures" — photos and images
"videos" — video files
```
Expose as `pub const &str` values in `mod role`, not as an enum — the registry is
extensible and unknown roles must pass through without error. Clients MUST ignore
unrecognised role values (§3.1).
### FileNodeCapability (account-level, §2.1)
The value of the `urn:ietf:params:jmap:filenode` key in `accountCapabilities`.
This struct lives here (not in jmap-filenode-server) because clients also need to
read session capabilities.
| `maxFileNodeDepth` | `UnsignedInt\|null` | max hierarchy depth; null = no limit |
| `maxSizeFileNodeName` | `UnsignedInt` | max name length in UTF-8 octets; MUST be ≥ 100 |
| `forbiddenNameChars` | `String\|null` | characters forbidden in names; null = no restriction |
| `forbiddenNodeNames` | `String[]\|null` | complete names forbidden (case-insensitive); null = no restriction |
| `fileNodeQuerySortOptions` | `String[]` | supported sort properties for FileNode/query |
| `mayCreateTopLevelFileNode` | `Boolean` | user may create nodes with null parentId |
| `webTrashUrl` | `String\|null` | web URL for trash folder; null if unavailable |
| `caseInsensitiveNames` | `Boolean` | server treats names as case-insensitive for collision checks |
| `webUrlTemplate` | `String\|null` | URI Template (level 1) with `{id}` for viewing a node on the web |
| `webWriteUrlTemplate` | `String\|null` | URI Template (level 1) with `{id}` for direct HTTP writes |
### FileNodeFilterCondition and FileNodeComparator (§3.2.5)
Query filter conditions for `FileNode/query`. All fields are optional; a node
matches a condition only if every provided field matches.
| `isTopLevel` | `Boolean` | must have null parentId |
| `parentId` | `Id` | exact match on parentId |
| `ancestorId` | `Id` | any ancestor has this id |
| `descendantId` | `Id` | node is an ancestor of this id (inverse of ancestorId) |
| `nodeType` | `String` | exact match on nodeType |
| `role` | `String` | exact match on role |
| `hasAnyRole` | `Boolean` | true = role is non-null; false = role is null |
| `blobId` | `Id` | exact match on blobId |
| `isExecutable` | `Boolean` | exact match on executable flag |
| `createdBefore` | `UTCDate` | created < this date |
| `createdAfter` | `UTCDate` | created ≥ this date |
| `modifiedBefore` | `UTCDate` | modified < this date |
| `modifiedAfter` | `UTCDate` | modified ≥ this date |
| `accessedBefore` | `UTCDate` | accessed < this date |
| `accessedAfter` | `UTCDate` | accessed ≥ this date |
| `minSize` | `UnsignedInt` | size ≥ this value |
| `maxSize` | `UnsignedInt` | size < this value |
| `name` | `String` | exact byte match on name |
| `nameMatch` | `String` | glob match on name (case-insensitive; `*`, `?`, `[abc]`, `[!abc]`) |
| `type` | `String` | exact byte match on type (media type) |
| `typeMatch` | `String` | glob match on type using same glob syntax as nameMatch |
| `body` | `String` | full-text search in blob content |
| `text` | `String` | equivalent to body OR nameMatch OR typeMatch |
Note: the filter field `type` collides with a Rust keyword. Use
`#[serde(rename = "type")]` on the struct field, naming it `media_type` in Rust.
Sort comparator `property` values (§3.2.5, in addition to standard RFC 8620 properties):
- `"name"` — alphabetical
- `"type"` — directories first, then by media type
- `"size"` — ascending byte count
- `"created"` — ascending creation time
- `"modified"` — ascending modification time
- `"nodeType"` — directories, then symlinks, then files
- `"tree"` — depth-first recursive by name (like `find` output with `depth` parameter)
## Key Design Decisions
### nodeType inference on create
The spec (§3.1) says: if `nodeType` is absent on create, the server infers it —
`"file"` if `blobId` is non-null, `"symlink"` if `target` is non-null, `"directory"`
otherwise. This is server-side logic in `jmap-filenode-server`; this crate simply
uses `Option<NodeType>` for the field so absence is representable during
deserialization of create requests.
### type field naming
The wire field is literally `"type"`. In Rust this requires `#[serde(rename = "type")]`
and a field name of `media_type` (or similar). Document this explicitly so maintainers
do not inadvertently rename the serde attribute.
### modified and accessed semantics
Both are client-managed (§3.1, §8.1). The server does NOT automatically update them
on change — that is the client's responsibility. Setting either to `null` in an update
signals the server to set it to the current time. This is different from `changed`
(§3.1), which the server automatically updates on every mutation and is never settable
by clients. These are separate fields with different mutability semantics; `Option`
alone does not capture the null-means-reset-to-now semantic. Document this in field
doc comments.
### shareWith value type
`shareWith` is `Id[FilesRights]` — a JSON object whose keys are JMAP `Id`s and
whose values are `FilesRights` objects. In Rust: `Option<HashMap<Id, FilesRights>>`.
The `null` case (user lacks mayShare or node is not shared) maps to `None`.
### Blob lifetime guarantee
A blob referenced by a FileNode MUST NOT be garbage-collected by the server while
the FileNode exists (§3.1). This is an invariant the server backend must enforce;
the type crate simply documents it on the `blob_id` field.
### Filter union — untagged enum with Operator first
`FileNodeFilter` uses the same `#[serde(untagged)]` pattern as `jmap-mail-types`:
`Operator` variant before `Condition`. Serde tries variants in declaration order;
`FilterOperator` requires an `"operator"` key and fails fast when absent.
`FileNodeFilterCondition` must NOT use `#[serde(deny_unknown_fields)]`.
## Module Layout
```
src/
lib.rs re-exports
filenode.rs FileNode, NodeType, FilesRights, role constants module
query.rs FileNodeFilterCondition, FileNodeFilter, FileNodeComparator
capability.rs FileNodeCapability (account-level capability struct)
```
## Test Oracle Strategy
Tests must use independent oracles — never derive expected values from the code
under test. Acceptable sources:
1. Hand-written JSON fixtures constructed directly from draft-ietf-jmap-filenode-13
field descriptions (committed in `tests/fixtures/`).
2. Literal JSON from the capability example in §2.1.1 (copy-pasted from the draft).
3. Known wire values verified against the draft text.
All tests are `#[test]` (no tokio). Roundtrip tests (`serialize → deserialize`)
verify serde consistency but are not a substitute for spec-grounded oracle tests.
Key cases to cover:
- `FileNode` with `nodeType: "file"`, non-null `blobId`, null `target`
- `FileNode` with `nodeType: "directory"`, null `blobId`, null `target`, null `size`
- `FileNode` with `nodeType: "symlink"`, null `blobId`, non-null `target`
- `FileNode` with `nodeType: "unknown-future-type"` deserializes as `NodeType::Other`
- `shareWith` absent vs null vs populated map
- `role` with a known value, an unknown value (preserved as string), and null
- `FileNodeCapability` from the §2.1.1 example JSON
- `FileNodeFilterCondition` with the `type` field (verifying rename works)
- `FileNodeFilter` as both a bare condition and as an `operator` compound
## Spec References
- `~/PROJECT/jmap-chat-spec/references/draft-ietf-jmap-filenode-13.txt` — normative
- `~/PROJECT/jmap-chat-spec/references/rfc8620.txt` — base protocol (Id, UTCDate,
Filter, Comparator, State)
## Dependencies
```toml
jmap-types = { path = "../crate-jmap-types" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# No tokio, no async, no network deps
```
## Type-design constraints
### Extras-preservation policy (JMAP-lbdy)
Every public `Deserialize` struct that appears on the JMAP wire carries an
`extra` field per the workspace extras-preservation policy (see workspace
`AGENTS.md`):
```rust
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
```
In scope in this crate (each has a round-trip preservation test):
- `FilesRights`, `FileNode` (filenode.rs).
Out of scope:
- `FileNodeFilterCondition` (filter.rs) — filter algebra, per workspace
policy "filter algebra excluded".
- `FileNodeCapability` (capability.rs) — capability object, consistent
with the canonical extension-types template treating capabilities as
Session-shape objects rather than data objects.
- `NodeType`, `NodeRole` — string enums; result-enum `Other(String)`
variants are tracked separately.
### New-type rule
Any new public `Deserialize` struct added to this crate that appears on
the JMAP wire MUST include the `extra` field from day one with the
documented serde attributes and at least one round-trip preservation
test.
## JMAP Object Metadata `relatedType` declarations
The JMAP Object Metadata extension
([draft-ietf-jmap-metadata-01](https://datatracker.ietf.org/doc/draft-ietf-jmap-metadata/))
defines a companion `Metadata` object keyed by `(relatedType, relatedId)`
that attaches vendor-defined `Annotation`s — and, for some types, WebDAV
`WebDavMetadata` records — to objects defined elsewhere in the workspace.
The data types in this crate that are valid `relatedType` values:
| `FileNode` | `Annotation`; `WebDavMetadata` (draft §2.1.3 SHOULD) |
Servers that declare `urn:ietf:params:jmap:metadata` MAY restrict the
set of supported `relatedType`s via the capability's `dataTypes`
property.
Implementation crates: `jmap-metadata-types`, `jmap-metadata-server`,
`jmap-metadata-client` (bd JMAP-06zp).