### Zoho WorkDrive — a JSON:API envelope, wrong ids that answer `[]`, and paging that lies about having more

WorkDrive is the odd service in zone. Four things cause most failures: it speaks **JSON:API** (`data.type` / `data.attributes`) where every other Zoho service returns flat JSON; a record is **enormous** (~14 KB each) so a naive listing floods the context; giving a command the **wrong kind of id** returns an empty list instead of an error; and `meta.has_next` is **`false` even when more rows exist**.

#### The envelope

Every read comes back as `{"data": …}` where each record is `{ id, type, attributes, relationships, links }` — the payload you want is under `attributes`, never at the top:

```bash
zone workdrive user me --json      # → data.attributes.email_id, .zuid, .preferred_team_id
```

Writes take the mirror shape. `zone` builds it for the ergonomic commands and sends the required `application/vnd.api+json` headers itself; check any body before it goes out:

```bash
zone workdrive file rename <id> --name "Q3 report.pdf" --dry-run
# body: {"data":{"type":"files","attributes":{"name":"Q3 report.pdf"}}}
```

Anything taking `--data` needs the full envelope typed out — `{"data":{"type":"files","attributes":{…}}}`. A bare `{"name":"…"}` is rejected.

#### The id chain: user → team → workspace → folder → file

```bash
zone workdrive user me                      # data.attributes.zuid and .preferred_team_id
zone workdrive team of-user <zuid>          # every team this user belongs to
zone ctx workdrive teamId=<teamId>          # fills :teamId in /teams/<id>/… paths
zone workdrive workspace list               # the Workspaces (Team Folders) in that team
zone workdrive folder list <workspaceId>    # children of the workspace root
```

`team of-user` can return **more than one team with the same display name** — this org returns two, and only one holds the real Workspaces. Pick by listing workspaces, not by name; `user me`'s `preferred_team_id` is the one the web UI opens and was the right one here.

Going the other way, breadcrumbs resolve a bare resource id to its whole ancestry:

```bash
zone workdrive file breadcrumbs <resourceId>   # attributes.parent_ids[] — res_type team|workspace|folder
```

#### A wrong id does not error — it returns an empty list

```
zone workdrive workspace get <folderId>   → {"data": []}      # not a 404
zone workdrive folder list <fileId>       → {"data": []}      # not a 404
```

Both read as "it's empty". `/teamfolders/:id` accepts only a Workspace id; `/files/:id/files` only a container. A **malformed** id is the one that fails loudly — HTTP 400 `F6016 "URL Rule is not configured"`, exit 5.

#### Files and folders are the same resource

Everything under `/files` has `type: "files"`. What it actually is lives in `attributes`:

| `attributes.type` | `is_folder` | What it is |
|---|---|---|
| `folder` | `true` | a folder |
| `workspace` | **`false`** | a Workspace root — behaves as a folder, but `is_folder` is false |
| `file`, `pdf`, `zohosheet`, `mp4`, … | `false` | a file; `extn` carries the extension |

So branch on `attributes.type`, not on `is_folder`. `zone workdrive folder get <id>` and `zone workdrive file get <id>` hit the same path and both answer for either kind. `zone workdrive folder list` returns files *and* subfolders mixed (folders first); `zone workdrive workspace folders` / `zone workdrive workspace files` split the same set in two.

#### Paging: `has_next` is not to be trusted

Verified against a workspace with 499 root files:

| Call | Rows | `meta` |
|---|---|---|
| `workspace files <id> --limit 200` | 200 | `has_next: false` |
| `… --limit 200 --offset 200` | 200 | `has_next: false` |
| `… --limit 200 --offset 400` | 99 | `has_next: false` |

**Page until a page comes back shorter than the limit** — the only reliable signal. Most listings (`folder list`, `file versions`, `team recent-files`, `user privatespace-files`, `share links`) return no `meta` at all, so there is nothing else to read.

The per-page cap is not uniform: file and folder listings and `workspace search-records` clamp at **200**, while `team recent-files` clamps at **50** — silently, with the extra rows just missing. Ask for more than you expect once, and take the returned count as the real cap.

Two different flag pairs exist and are not interchangeable — the wrong one exits 2 with `unknown option`:

- `--limit` / `--offset` → `folder list`, `workspace folders`, `workspace files`, `file versions`, `user privatespace-files`
- `--page-limit` / `--page-offset` → `workspace search-records`, `workspace get-trashed-files`, and everything under `team`

Offsets are 0-based row counts and do not overlap.

#### Records are huge — always narrow before printing

A file record carries ~70 attributes. Measured live: **50 rows of `folder list` = 914 KB of JSON.** Never let one reach the transcript. Write it out and filter locally:

```bash
zone workdrive folder list <folderId> --limit 200 --out ./page.json
zone workdrive workspace search-records <teamId> --search-name "invoice" --page-limit 50 --out ./hits.json
```

Search is far cheaper than walking a tree and is the only call reporting a real total (`meta.search_result_count`). Filters: `--search-all`, `--search-name`, `--search-content`, `--filter-type` (`folder` is a valid value), `--filter-team-folder <workspaceId>`, `--filter-creator`, `--filter-from-date` / `--filter-to-date`.

#### Upload and download

```bash
zone workdrive file download <fileId> --out ./report.pdf
zone workdrive file upload --file ./report.pdf --parent <folderId> --name report.pdf
```

Upload is `multipart/form-data`: the file rides in a part named `content`, `--parent` becomes the `parent_id` form field and `--name` becomes `filename`. There is no path — the destination *is* `--parent`, and it must be a folder or Workspace id. Extra form fields go through `--data` as flat JSON (Zoho's `override-name-exist=true` stores the upload as a new version of an existing file rather than a duplicate). Large files use the three-step session: `zone workdrive uploadsession chunk-upload-create-session --size <bytes> --file-name <n> --parent-id <id>`, `zone workdrive workdrive chunk-file-upload`, `zone workdrive uploadsession chunk-upload-commit-session --upload-id <id> --parent-id <id> --file-name <n>`.

#### Sharing is two separate systems

```bash
zone workdrive share permissions <resourceId>   # internal members
zone workdrive share links <resourceId>         # external share links
```

`share permissions` returning `{"data": []}` does **not** mean unshared — it means no permission is set *directly on that resource*; the effective access is inherited, and `meta.resource_share_info` names the parent it comes from and its collaborator count. To see who really has a Workspace, use `zone workdrive workspace get-team-folder-shared-users <workspaceId>`.

`role_id` grades access. Observed live: `1` admin, `2` organizer on members; `6` and `34` on external links (Zoho also documents `5` editor). Read an existing member or link before assuming a number.

External links are published URLs on `workdrive.zohoexternal.com` carrying `allow_download`, `is_password_protected` and `is_expired`. `zone workdrive share link-create` puts content outside the org — treat it as a publish, not a read.

#### What cannot be undone

`zone workdrive trash add <id>` is a soft delete (`status: 51`) and `zone workdrive trash restore <id>` (`status: 61`) brings it back. Everything below bypasses Trash:

| Command | Effect |
|---|---|
| `zone workdrive trash delete <id>` | permanent removal of one resource |
| `zone workdrive trash bulk-delete --data '{…}'` | the same, for many at once |
| `zone workdrive action empty-my-folder-trash --data '{…}'` | empties My Folders trash |
| `zone workdrive version delete <versionId>` | destroys one stored version |
| `zone workdrive workspace delete <teamFolderId>` | removes a Workspace and its contents |

`zone workdrive file move <id> --parent <folderId>` is reversible only if you recorded the old `parent_id` first — read it with `file get` before moving. For a session that should not write at all, `--deny '*:DELETE'` or `ZONE_POLICY='*:GET'`.

#### Errors → what to do

| What you see | Meaning | Do |
|---|---|---|
| `Missing <teamId> for this command` | no team in ctx | `zone workdrive user me`, then `zone ctx workdrive teamId=<preferred_team_id>` |
| `{"data": []}` from a `get` | the id is of the wrong kind | resolve it with `zone workdrive file breadcrumbs <id>` |
| HTTP 400 `F6016 URL Rule is not configured` | malformed / unknown resource id | re-read the id; WorkDrive ids are ~36 chars, not numeric |
| HTTP 401 `R008 Unauthorized access` on one endpoint while others work | that endpoint needs a scope or plan tier this token lacks | not a stale token — check `attributes.capabilities` on the resource first |
| a loop that stops after one page | `meta.has_next` was `false` while rows remained | page until a page is shorter than the limit |
| rows silently missing | `--limit`/`--page-limit` clamped (200, or 50 on `team recent-files`) | page with the clamped size |
| `error: unknown option '--limit'` (exit 2) | that command uses `--page-limit` | swap the pair; see the split above |
| an empty `share permissions` on a file people can clearly open | access is inherited from the parent | read `meta.resource_share_info`, or `workspace get-team-folder-shared-users` |
| exit 3 | not signed in | human runs `zone login workdrive` |
| exit 4 | token lacks the WorkDrive scopes | human re-logs in that service |

> Command names, paths, required flags and HTTP verbs come from the installed specs, so they are exact. Everything else was **verified live against a real WorkDrive team** (133 users, 23 Workspaces): the JSON:API envelope, the two-teams-one-name trap, the `{"data": []}` answers for a wrong id, the `F6016` and `R008` errors, the 200/50 caps, `has_next: false` across all three pages of a 499-row listing, the 914 KB-per-50-rows size, the `type` values, breadcrumbs, the inherited-permission `meta`, the observed `role_id`s, and a real download. Only reads ran — nothing was uploaded, moved, shared, trashed or deleted; the write bodies come from `--dry-run` and the specs. The `override-name-exist` form field and the `5`/`6` role ids are Zoho's documentation, not re-verified.
