# How-to: manage a document

**Goal:** create a document, edit its content through the check-out/check-in
lock, control who can touch it, and read its audit trail.

Assumes the [Quickstart](/docs/quickstart.html) (`$LYDIAN`, `$TENANT`, `$KEY`
exported). Documents are person-scoped, so you also need **at least one person
in your tenant** — every request below carries their id in the
`X-Acting-Person` header:

```bash
export ACTOR=YOUR_PERSON_ID     # any person in your tenant, e.g. Ada from the Quickstart
```

---

## 1. First, secure your footing (the bootstrap rule)

Document access is role-based. Two roles are seeded per tenant: **Admin**
(every permission) and **Reader** (read + download). Until someone holds a
tenant-wide role with the `MANAGE_ROLES` permission, *any* person may manage
roles and create documents — that's the bootstrap window that makes your first
document possible. The moment a tenant-wide `MANAGE_ROLES` assignment exists,
the window closes for everyone else.

So step one is always: grant your acting person tenant-wide Admin, explicitly.

```bash
ADMIN_ROLE=$(curl -s -H "X-API-Key: $KEY" \
  "$LYDIAN/tenants/$TENANT/document-roles" | jq -r '.[] | select(.name=="Admin") | .id')

curl -s -X POST "$LYDIAN/tenants/$TENANT/document-role-assignments" \
  -H "X-API-Key: $KEY" -H "X-Acting-Person: $ACTOR" \
  -H "Content-Type: application/json" \
  -d "{\"personId\": \"$ACTOR\", \"roleId\": \"$ADMIN_ROLE\"}" | jq
```

```json
{
  "id": "0198c1a0-4b2c-7f11-8a3e-55d0c9e7b210",
  "tenantId": "019fb5a2-9626-7ba6-ad59-b5f98cf04c8e",
  "personId": "0198c0e5-1f60-7e88-a3d4-92c7b81e6f42",
  "roleId": "0198c09c-2d17-7a45-b7f0-3c88a1d4e902"
}
```

No `documentId` in the body means the assignment is **tenant-wide**; include
one to scope a role to a single document. A person's effective permissions on
a document are the union of their tenant-wide and per-document assignments.

## 2. Create a document

Markdown-native documents go through JSON, `content` included:

```bash
DOC=$(curl -s -X POST "$LYDIAN/tenants/$TENANT/documents" \
  -H "X-API-Key: $KEY" -H "X-Acting-Person: $ACTOR" \
  -H "Content-Type: application/json" \
  -d '{
        "title": "Studio Policies",
        "description": "House rules for lessons and cancellations",
        "content": "# Studio Policies\n\nLessons cancelled with less than 24h notice are billed in full.\n"
      }' | jq -r .id)
```

The response is the document record — metadata plus version bookkeeping, never
the content itself:

```json
{
  "id": "0198c1a4-77d9-7c02-9b1f-e2a6f04d8c33",
  "tenantId": "019fb5a2-9626-7ba6-ad59-b5f98cf04c8e",
  "title": "Studio Policies",
  "description": "House rules for lessons and cancellations",
  "documentTypeId": "0198c09c-1a80-7e36-a1c2-77b4d9f0e651",
  "documentTypeVersion": {"major": 1, "minor": 0},
  "currentVersionNumber": 1,
  "createdBy": "0198c0e5-1f60-7e88-a3d4-92c7b81e6f42",
  "createdAt": "2026-08-05T14:12:03.418Z"
}
```

With no `documentTypeId` it lands on the Base document type. The creator
automatically gets Admin **on this document**. Binary files use the multipart
route instead: `POST /documents/upload` with `file`, `title`, and optional
`description`/`documentTypeId` form fields.

Content comes back from its own endpoint, in the stored MIME type. (Fetching
content requires the `DOWNLOAD` permission — `READ` alone covers metadata,
not content — and each fetch is audited as `DOWNLOADED`.)

```bash
curl -s -H "X-API-Key: $KEY" -H "X-Acting-Person: $ACTOR" \
  "$LYDIAN/tenants/$TENANT/documents/$DOC/content"
```

## 3. Edit content: check out, check in

Content is a chain of immutable versions; changing it requires the exclusive
lock. Check out:

```bash
curl -s -X POST -H "X-API-Key: $KEY" -H "X-Acting-Person: $ACTOR" \
  "$LYDIAN/tenants/$TENANT/documents/$DOC/checkout" | jq '{currentVersionNumber, checkedOutBy, checkedOutAt}'
```

```json
{
  "currentVersionNumber": 1,
  "checkedOutBy": "0198c0e5-1f60-7e88-a3d4-92c7b81e6f42",
  "checkedOutAt": "2026-08-05T14:20:41.007Z"
}
```

While the lock is held, any further checkout — even by the holder — and any
check-in by someone else is `409 STATE_CONFLICT`, and the document can't be
deleted. Check in writes
version 2 and releases the lock in one step:

```bash
curl -s -X POST "$LYDIAN/tenants/$TENANT/documents/$DOC/checkin" \
  -H "X-API-Key: $KEY" -H "X-Acting-Person: $ACTOR" \
  -H "Content-Type: application/json" \
  -d '{
        "content": "# Studio Policies\n\nLessons cancelled with less than 24h notice are billed at half rate.\n",
        "comment": "Soften the cancellation policy"
      }' | jq '{currentVersionNumber, checkedOutBy}'
```

`currentVersionNumber` is now `2` and `checkedOutBy` is gone. To abandon
instead of committing, `POST …/checkout/release`; a holder of the
`FORCE_RELEASE` permission can break a stale lock with
`POST …/checkout/release?force=true`.

Two things that do **not** need the lock: metadata edits and reading.
`PUT /documents/{id}` replaces the metadata wholesale — title, description,
attribute values, and optionally `documentTypeId` — so send the full desired
state, not a delta. Old versions stay readable forever:

```bash
curl -s -H "X-API-Key: $KEY" -H "X-Acting-Person: $ACTOR" \
  "$LYDIAN/tenants/$TENANT/documents/$DOC/versions" | jq 'map({versionNumber, checkinComment, createdAt})'
curl -s -H "X-API-Key: $KEY" -H "X-Acting-Person: $ACTOR" \
  "$LYDIAN/tenants/$TENANT/documents/$DOC/versions/1/content"
```

## 4. Grant access to others

Give a second person read-only access to this one document — Reader role,
scoped by `documentId`:

```bash
READER_ROLE=$(curl -s -H "X-API-Key: $KEY" \
  "$LYDIAN/tenants/$TENANT/document-roles" | jq -r '.[] | select(.name=="Reader") | .id')

curl -s -X POST "$LYDIAN/tenants/$TENANT/document-role-assignments" \
  -H "X-API-Key: $KEY" -H "X-Acting-Person: $ACTOR" \
  -H "Content-Type: application/json" \
  -d "{\"personId\": \"$OTHER_PERSON\", \"roleId\": \"$READER_ROLE\", \"documentId\": \"$DOC\"}"
```

Permission strings are **uppercase on the wire** (`READ`, `CHECKOUT`,
`MANAGE_ROLES`, …) — `"read"` is a `400 INVALID_REQUEST`. Custom roles are
just named permission sets: `POST /document-roles` with `{name, description,
permissions: ["READ", "CHECKOUT", "CHECKIN"]}`. Note that `CREATE` and
`MANAGE_ROLES` only have meaning tenant-wide.

Search respects all of this — results only contain documents the acting
person may read:

```bash
curl -s -H "X-API-Key: $KEY" -H "X-Acting-Person: $ACTOR" \
  "$LYDIAN/tenants/$TENANT/documents/search?q=cancellation" | jq 'map({title: .document.title, score})'
```

(Hits wrap the whole document: read `hit.document.title`, not `hit.title`.)

## 5. Read the audit trail

Every state change — never reads, but yes downloads — appends an immutable
entry:

```bash
curl -s -H "X-API-Key: $KEY" -H "X-Acting-Person: $ACTOR" \
  "$LYDIAN/tenants/$TENANT/documents/$DOC/audit" | jq 'map({action, at, versionNumber, details})'
```

```json
[
  {"action": "CREATED",     "at": "2026-08-05T14:12:03.418Z", "versionNumber": 1, "details": {"title": "Studio Policies", "mimeType": "text/markdown"}},
  {"action": "CHECKED_OUT", "at": "2026-08-05T14:20:41.007Z", "versionNumber": 1},
  {"action": "CHECKED_IN",  "at": "2026-08-05T14:24:19.552Z", "versionNumber": 2, "details": {"comment": "Soften the cancellation policy", "mimeType": "text/markdown"}},
  {"action": "GRANT_ADDED", "at": "2026-08-05T14:31:02.114Z", "details": {"grantee": "…person id…", "roleId": "…role id…"}}
]
```

Filter with `?from=&to=` (instants) and `?action=`, one of the thirteen
`DocumentAuditAction` values: `CREATED`, `METADATA_UPDATED`, `CHECKED_OUT`,
`CHECKOUT_RELEASED`, `CHECKOUT_FORCED`, `CHECKED_IN`, `DOWNLOADED`,
`DELETED`, `GRANT_ADDED`, `GRANT_REMOVED`, `ROLE_CREATED`, `ROLE_UPDATED`,
`ROLE_DELETED`. (Note the force-release action is `CHECKOUT_FORCED` — there
is no `FORCE_RELEASED`; that spelling exists only as the *permission*
`FORCE_RELEASE`.) Reading the trail needs the `READ_AUDIT` permission. The
trail outlives the document — deletion is itself the final `DELETED` entry —
and is removed only with the tenant.

---

## Next

- Checking in a document fires a **system notification** to interested people
  — see [work with the inbox](/docs/work-with-the-inbox.html).
- Attach documents to messages via `attachmentDocumentIds` on any
  communication.
