# Authentication & conventions

The cross-cutting mechanics every integration hits: how auth works, what
errors look like, how time is formatted — and, just as importantly, which
common API conveniences this platform deliberately does **not** have yet.
Nothing on this page is aspirational; it describes the API as deployed.

---

## Authentication

### The API key

Every request (except `/health`) carries a tenant-scoped key in the
`X-API-Key` header:

```bash
curl -H "X-API-Key: lyd_…" https://platformlydian.com/api/tenants/{tenantId}/people
```

Keys look like `lyd_` + 43 characters. Each key is issued for one registered
app in one tenant and reaches only that tenant's paths.

**Issuance is operator-side, not self-serve.** The app-registration endpoints
(`/tenants/{tenantId}/apps` and its `rotate`/`revoke` actions) require the
platform's internal admin credential — calling them with an API key returns
`403` by design. You receive your tenant id and key from the operator out of
band. The plaintext key appears exactly once, in the registration (or
rotation) response; the platform stores only a hash and cannot show it again.

- **Rotation** — the operator issues a new key for your app; the old key stops
  working the moment the new one exists. Ask for rotation whenever a key may
  have leaked.
- **Revocation** — soft-disables the app; the registration record survives for
  audit. A revoked key gets `401`.

Treat the key as a server-side secret with full read/write over your tenant.
Your end users should never hold it — put your own auth in front.

### The acting person

Document and communication endpoints additionally require an
`X-Acting-Person` header carrying the id of a person **in your tenant** — the
platform uses it for permissions, audit attribution, and inbox scoping.
Missing or unknown person: `401`. The exemptions, exactly: three read-only
operations (listing document roles, fetching one document role, and listing
role assignments), the type-definition endpoints (`/document-types`,
`/communication-types`), and the tenant-wide communications purge
(`DELETE /tenants/{tenantId}/communications`, an admin action). Every other
document and communication route requires the header.

The header is attribution, not authentication — any key holder can name any
person. The key is the security boundary.

### Environments

| Environment | Base URL | Notes |
|---|---|---|
| Development | `https://platformlydian.com/dev-api` | separate database, separate keys |
| Production | `https://platformlydian.com/api` | operator issues prod keys separately |
| Direct | `/` (no prefix) | a stack you run yourself — local dev or the self-contained tester bundle — serves the API at the bare root, normally with auth off |

These are exactly the three `servers` entries in the OpenAPI spec. A dev key
on prod (or vice versa) is `401` — the deployments share nothing.
`GET /health` is anonymous everywhere and suits liveness checks.

---

## Errors

Every non-2xx response carries a single JSON envelope:

```json
{
  "code": "REFERENCE_BLOCKED",
  "message": "Attribute 'Instrument' is referenced by one or more PeopleTypes and cannot be deleted",
  "details": []
}
```

**Branch on `code`, display `message`.** Codes are stable and machine-readable
— never renamed or removed, only added. `message` is human prose and may be
reworded. `details` is populated only for `VALIDATION_FAILED`, as
`{field, message}` entries naming each failing request-body field.

| Status | `code` | Meaning |
|---|---|---|
| `400` | `VALIDATION_FAILED` | a request-body field failed validation — see `details` |
| `400` | `INVALID_REQUEST` | well-formed but semantically invalid — bad value, unknown attribute id, malformed date |
| `401` | `UNAUTHENTICATED` | missing/unknown/revoked API key, or missing/unknown acting person |
| `403` | `FORBIDDEN` | valid key, but the path is outside your tenant, or the acting person lacks a document permission |
| `404` | `NOT_FOUND` | no such record under your tenant (foreign-tenant record ids also land here — existence isn't leaked) |
| `409` | `STATE_CONFLICT` | a lifecycle rule blocks the operation — wrong invoice status, checkout lock held, message already sent |
| `409` | `REFERENCE_BLOCKED` | a delete is blocked by records that still reference the target |
| `500` | `INTERNAL` | unexpected server failure |

The two `409`s encode different remediations, worth distinguishing in your UX:
`REFERENCE_BLOCKED` is fixed by changing *other* records (the message names
the blocker); `STATE_CONFLICT` by an action on the record itself. References
never cascade on this platform, so `REFERENCE_BLOCKED` is a first-class
outcome of normal operation, not an edge case.

One boundary subtlety: a foreign **tenant path** is `403` (your key
structurally can't reach it); a foreign **record id under your own tenant
path** is `404`.

---

## Timestamps, dates, and money

- **Instants** (`event.startAt`, `payment.paidAt`, `document.createdAt`,
  `communication.sentAt`, …) are ISO-8601 UTC instants:
  `2026-07-30T14:00:00Z`. Persisted to millisecond precision — anything finer
  is dropped once stored (the immediate write response may still echo finer
  precision than a later read returns).
- **Calendar dates** (`invoice.issueDate`, `invoice.dueDate`, `date`-kind
  attribute values) are plain ISO dates: `2026-07-30`. No time, no zone.
- **Time-range queries** (`/events?from=&to=`, `/payments?from=&to=`,
  `/documents/{id}/audit?from=&to=`) take instants; `from` is inclusive, `to`
  exclusive.
- **Money** is JSON numbers. Payment amounts with more than two decimals are
  rejected (`400`); line-item `unitPrice`/`quantity` accept finer precision,
  but the derived amounts are what count. Line amounts (`quantity ×
  unitPrice`) and invoice totals are derived server-side (scale 2, HALF_UP)
  and never persisted or accepted from clients. One ISO-4217 `currency` per
  invoice (default `USD`), no conversion.

## Versioning semantics

Attributes and the five type kinds carry a `{major, minor}` version, and every
record snapshots its type's version at creation time (`peopleTypeVersion`,
`eventTypeVersion`, `documentTypeVersion`, `invoiceTypeVersion`,
`communicationTypeVersion`). The declared semantics: minor = backwards
compatible, major = breaking with data migration.

**Current state, stated plainly:** everything is created at `1.0` and no API
operation bumps a version — type updates modify the schema in place without
changing the version. The stamps exist so future schema evolution has
something to stand on; today you can read them, but they will all say `1.0`.

## Response-shape conventions

- **Empty collections and nulls are omitted**, not serialized as `[]`/`null`.
  A fresh draft invoice has no `lineItems` key; a record with no attribute
  values has no `attributeValues` key. Read defensively:
  `invoice.lineItems ?? []`.
- **`isBase` serializes as `base`**: type responses carry `"base": true/false`.
- **Ids are UUIDs** (UUIDv7, time-ordered). Attribute ids are UUID *strings* —
  and `attributeValues` maps are keyed by attribute id, never by name.
- **Server-managed fields** (`invoiceNumber`, `status`, versions, timestamps,
  derived totals) are silently ignored if sent; you never write them.

---

## Not implemented — do not design around these

Confirmed absent from the deployed API. Anything here that a generic API
client "expects" will simply not be there:

- **Pagination.** No cursor, offset, or page parameters anywhere. Every list
  endpoint returns the complete collection in one response. The only limit
  parameter is `GET /documents/search?limit=` (default 20), which caps search
  hits. Plan your data volumes and UI accordingly.
- **Idempotency keys.** No `Idempotency-Key` (or equivalent) support on
  writes. A retried `POST` creates a second record. Build client-side
  dedup/confirmation if you need exactly-once semantics.
- **Rate limits.** No server-side rate limiting, and no `429`s or
  `X-RateLimit-*` headers. Be a polite client; nothing will throttle you
  before the infrastructure does.
- **Webhooks / push.** Nothing calls you back. System notifications are
  records you poll — `GET /communications/unread-count` is the cheap poll.
- **End-user authentication.** No user or login model; only the API key and
  the unauthenticated acting-person header. User identity is your app's job.
- **Sorting and filtering** beyond the documented query parameters
  (`/events?from=&to=`, `/invoices?status=&recipientPersonId=`,
  `/payments?from=&to=`, `/communications/inbox?channel=&unreadOnly=`,
  `/documents/{id}/audit?from=&to=&action=`,
  `/document-role-assignments?personId=&documentId=`).
- **Version bumps** on attributes and types, as described above.

---

## Machine-readable surface

For client generators and AI agents:

- OpenAPI spec: [/openapi.yml](/openapi.yml) (also `/openapi.yaml`,
  `/openapi.json`, `/docs/openapi.yml`)
- Docs index for agents: [/llms.txt](/llms.txt) — every page on this site has
  a raw Markdown source next to it (this one: `/docs/conventions.md`).
