# Agentic Envelope Spec — `agentic-envelope/1.0`

This document defines the single JSON object returned for every `/.agentic/<path>`
mirror in Sojourncase. It is the wire contract that lets an LLM agent read screen
state, navigate the app, and execute mutations without scraping the human SPA.

- **Human app:** `https://sojourn.cross-fare.com/<path>` (React SPA, served static).
- **Agentic mirror:** `https://sojourn.cross-fare.com/.agentic/<same-path>`.
  - nginx rewrites `/.agentic/` → `/api/agentic/`; the Rust/Axum API renders the
    envelope. The agent only ever needs to know the `/.agentic/<path>` form.
- **REAL API:** `https://sojourn.cross-fare.com/api/*` (Bearer-JWT). This is where
  mutations actually land. The envelope's `actions[].href` point **here**, never at
  a mirror.

Most data is case-scoped: routes live under `/.agentic/<surface>/{caseId}` and the
underlying API under `/api/cases/{caseId}/...`. Auth is the same Bearer JWT used by
the SPA — send `Authorization: Bearer <token>` to both `/.agentic/*` (read) and
`/api/*` (write).

---

## 1. Media type

```
Content-Type: application/vnd.sojourncase.agentic+json
```

Every `/.agentic/<path>` response carries this media type (not `application/json`).
An agent can content-negotiate it; a `200` with this type is a valid envelope.
The error shape on `/.agentic/*` is the API's normal JSON error body with the
ordinary `application/json` type — a non-envelope response signals a non-2xx.

---

## 2. Top-level shape

One envelope = one JSON object with exactly these top-level keys:

```jsonc
{
  "@context": "...",      // JSON-LD context (string or object)
  "@type":    "...",      // JSON-LD type for the primary entity/screen
  "@id":      "...",      // canonical agentic URL of THIS resource (== _links.self.href)
  "data":     { ... },    // flat camelCase entity / screen state
  "_links":   { ... },    // HAL: SAFE GET navigation to sibling /.agentic routes
  "actions":  [ ... ],    // Siren-style UNSAFE mutations -> the REAL /api
  "meta":     { ... }     // envelope metadata + per-rel/per-action doc anchors
}
```

No additional top-level keys. Everything screen-specific lives inside `data`.

### 2.1 JSON-LD sprinkle (`@context` / `@type` / `@id`)

A light JSON-LD dusting so the envelope is self-describing to linked-data tools:

- `@context` — context IRI/object for vocabulary resolution.
- `@type` — the type of the primary thing this route represents
  (e.g. `Tracker`, `Case`, `Draft`, `CreditAccount`, `Collection`).
- `@id` — the **canonical agentic URL of this exact resource**. It MUST equal
  `_links.self.href`. This is the stable identity an agent can dereference to
  re-fetch the same envelope.

The JSON-LD keys are advisory. `data`, `_links`, `actions`, `meta` are the
load-bearing contract.

---

## 3. `data` — flat screen/entity state

`data` is the machine-readable mirror of what the human screen shows. Rules:

1. **Flat, camelCase.** Keys are camelCase (`nextDeadline`, `countByStatus`,
   `signedOff`). Nesting is allowed where the domain is genuinely nested
   (a tracker's `fields[]`, `records[]`), but field names never use snake_case —
   this matches the API DTOs, which are `#[serde(rename_all = "camelCase")]`.
2. **Enums as `{code,label}`.** Any closed-vocabulary value is rendered as an
   object with a stable machine `code` and a human `label` (see §6). The agent
   keys off `code`; `label` is for display/logging only.
3. **ISO-8601 dates.** All timestamps and dates are ISO-8601 strings (UTC,
   e.g. `2026-06-25T14:03:00Z`, or a date `2026-06-25`). No epoch ints, no
   locale strings. `meta.generatedAt` is likewise ISO-8601.
4. **IDs are strings.** Entity ids (`caseId`, `trackerId`, `recordId`, `fieldId`,
   `taskId`, `docId`, `groupId`) are opaque string UUIDs, passed verbatim into
   action `href` path params.
5. **No secrets / no write affordances in `data`.** `data` is read-only state.
   Anything an agent can *do* lives in `actions`; anywhere it can *go* lives in
   `_links`.

For a **collection** route (e.g. `GET /.agentic` accessible cases, or a records
list), `data` carries a summary plus an array; per-item deep links go in the
items, and the collection-level navigation goes in `_links`.

---

## 4. `_links` — HAL, SAFE GET navigation only

`_links` is a HAL link map: `rel -> { href, title, type?, templated? }`.

- **`href` is always an `/.agentic/<path>` URL** (a sibling mirror), never `/api/*`.
- **GET only.** Following any `_links` entry is a safe, idempotent navigation.
  There are **no verbs** in `_links` — a link never mutates.
- **`self` is mandatory.** Every envelope includes `_links.self`, and
  `_links.self.href == @id`.
- **`describedby` is mandatory.** Points at the API documentation for this
  resource (the OpenAPI doc and/or the markdown api-reference anchor), so an
  agent can resolve full schemas: `{ "href": "/openapi.json", ... }` and the
  per-endpoint deep anchors are surfaced via `meta.docs` (§7).
- **`templated: true`** marks a URI-Template `href` (RFC 6570), e.g. a link to a
  record sheet `"/.agentic/build/{caseId}/{trackerId}/records/{recordId}"` the
  agent fills in. Omit `templated` for fully-resolved hrefs.
- **`type`** optionally pins the target media type
  (`application/vnd.sojourncase.agentic+json` for mirror links).

Typical rels: `self`, `describedby`, `up`/`case`, `today`, `build`, `evidence`,
`draft`, `credits`, `tracker`, `records`, `record`, `task`, `doc`, plus
collection rels like `item`/`items`, `next` (cursor paging).

---

## 5. `actions` — Siren-style UNSAFE mutations to the REAL `/api`

`actions` is an **array** (order is stable but not semantically significant).
Each action is the agent's permission slip to mutate state. Shape:

```jsonc
{
  "name": "addRecord",                       // stable operationId (see §8)
  "title": "Add record",                     // human label
  "method": "POST",                          // POST | PATCH | PUT | DELETE
  "href": "/api/cases/{caseId}/trackers/{trackerId}/records",  // REAL /api endpoint
  "templated": true,                         // href contains URI-Template vars
  "contentType": "application/json",         // request body media type
  "fields": [                                // body/param schema
    { "name": "id", "type": "string", "required": false,
      "description": "Client-supplied UUID; server mints one if absent/invalid." }
  ],
  "creditsCost": 0,                          // credits this action will spend (optional)
  "serverAuthoritative": false               // true => server computes the result; see §5.3
}
```

### 5.1 `href` points at the REAL API

`actions[].href` is an actual `/api/*` endpoint (Bearer-JWT, case-scoped). It is
**never** a `/.agentic/*` mirror. The agent issues the literal HTTP `method` to
that `href`. Path params (`{caseId}`, `{trackerId}`, …) are filled from `data`
ids; `templated: true` declares their presence.

### 5.2 `method` is the real verb

`POST` create, `PATCH` partial update, `PUT` full replace / idempotent set,
`DELETE` remove. These match the Axum router exactly (e.g. tasks update is `PUT`,
case patch is `PATCH`, claim toggle is `PUT`, column move is `POST`).

### 5.3 `fields`, `creditsCost`, `serverAuthoritative`

- **`fields[]`** — each `{ name, type, required, enum?, description }` describes a
  body property (or templated path/query param). `enum` lists allowed `code`
  values for closed vocabularies (e.g. `status: ["need","draft","final"]`).
  Required-but-nullable DTO keys are documented as `required:true` with a
  description noting `null` is accepted.
- **`creditsCost`** — fixed credit price the action will debit on success. Present
  and non-zero only for paid AI actions: `generateBrief` = **48**,
  `reviewBrief` = **24**, `autofillRecord` = **2**. All other actions either omit
  it or set `0`. The balance after the charge is returned by the API (see
  `DraftResp.balance` / `AutofillResp.balance`); the agent should treat the
  envelope's `creditsCost` as the quoted price, not the final truth.
- **`serverAuthoritative`** — `true` means the **server**, not the agent, computes
  the substantive result; the agent supplies only inputs and must not assume it
  controls the output. `autofillRecord` is `serverAuthoritative: true` — it
  returns server-computed `suggestions` (fill / amend / confirm) that the agent
  then applies via `setRecordValue`/`updateRecord`. Generation/review are also
  effectively server-authoritative for their produced text.

> INVARIANT reminder: an action **always** mutates and **always** targets `/api`.
> If you want to *read* the result afterward, re-`GET` the relevant `_links` mirror.

---

## 6. Enum convention — `{code,label}`

Every closed-vocabulary value appears as:

```json
{ "code": "build", "label": "Build" }
```

- `code` — stable machine token, exactly the value the API stores/accepts.
- `label` — display string; may change, never keyed on.

Canonical vocabularies (confirmed against `apps/api/src/dto.rs::valid`):

| Domain field        | `code` values                          |
|---------------------|----------------------------------------|
| case `stage`        | `build`, `file`, `after`               |
| doc `status`        | `need`, `draft`, `final`               |
| doc `type`          | `file`, `link`, `draft`                |
| task `priority`     | `critical`, `high`, `medium`, `low` (nullable) |
| tracker field `type`| `text`, `date`, `select`, `link`       |
| strength `band`     | `s` (strong), `g` (gap), `t` (thin)    |
| finding `kind`      | `gap`, `rfe`, `boilerplate`            |
| docSource `kind`    | `task`, `record`                       |
| briefSource `kind`  | `exhibit`, `effort`, `field`           |
| group `kind`        | `req`, `user`                          |

In `actions[].fields[].enum`, list the `code` values only (the API validates the
raw `code`). In `data`, render the `{code,label}` pair so the agent gets the label
for free.

---

## 7. `meta` — envelope metadata + doc anchors

```jsonc
"meta": {
  "kind": "tracker",                         // short route kind
  "version": "agentic-envelope/1.0",         // FIXED literal
  "generatedAt": "2026-06-25T14:03:00Z",     // ISO-8601 render time
  "humanUrl": "https://sojourn.cross-fare.com/build/{caseId}/{trackerId}",
  "caseId": "5f1c...e9",                      // case scope (null for /.agentic, /.agentic/credits, catalog)
  "docs": {
    "openapi": "/openapi.json",
    "reference": "/agents.dir/api-reference.md",
    "endpoints": {
      // one key per _links rel AND per action name (see invariant 3)
      "self":        "/agents.dir/api-reference.md#agentic-tracker",
      "records":     "/agents.dir/api-reference.md#agentic-tracker-records",
      "describedby": "/openapi.json",
      "addRecord":   "/agents.dir/api-reference.md#addrecord",
      "addTrackerField": "/agents.dir/api-reference.md#addtrackerfield",
      "updateTracker":   "/agents.dir/api-reference.md#updatetracker",
      "deleteTracker":   "/agents.dir/api-reference.md#deletetracker"
    }
  }
}
```

- `kind` — short route discriminator (`entrypoint`, `today`, `build`, `tracker`,
  `record`, `task`, `evidence`, `doc`, `draft`, `case`, `credits`,
  `visa-catalog`, `visa-def`).
- `version` — the FIXED string `agentic-envelope/1.0`.
- `generatedAt` — ISO-8601 render timestamp.
- `humanUrl` — the SPA URL this envelope mirrors (for hand-off to a human).
- `caseId` — the case scope, or `null` for un-scoped routes (`/.agentic`,
  `/.agentic/credits`, `/.agentic/visa-types`, `/.agentic/new`).
- `docs.openapi` / `docs.reference` — the two doc sources.
- **`docs.endpoints`** — a map whose **keys are exactly `union(_links rels, action
  names)`**, each pointing at the deep doc anchor for that rel/action. Anchors in
  `api-reference.md` and operationIds in `openapi.json` use the same camelCase
  tokens (§8), so `#addrecord` and `/openapi.json#/paths/.../post` both resolve.

---

## 8. The three INVARIANTS

These hold for **every** envelope and are the contract an agent relies on:

1. **`_links` = SAFE GET → `/.agentic` only.** Every `_links[*].href` is an
   `/.agentic/<path>` mirror, dereferenced with GET, never mutating. `self` is
   always present and equals `@id`.
2. **`actions` = UNSAFE verbs → the REAL `/api`.** Every `actions[*].href` is an
   `/api/*` endpoint with a real mutating `method` (POST/PATCH/PUT/DELETE). No
   action ever points at a mirror.
3. **`meta.docs.endpoints` keys = `union(_links rels, action names)`.** Every
   navigable rel and every executable action has a deep doc anchor — no orphan
   rels, no undocumented actions. Anchor/operationId tokens are the stable
   camelCase operationIds (`login`, `getCase`, `createCase`, `patchCase`,
   `addTracker`, `updateTracker`, `deleteTracker`, `addTrackerField`, `addRecord`,
   `updateRecord`, `setRecordValue`, `autofillRecord`, `deleteRecord`, `addTask`,
   `updateTask`, `deleteTask`, `addColumn`, `addGroup`, `addDoc`, `setDocStatus`,
   `uploadDocFile`, `toggleClaim`, `generateBrief`, `reviewBrief`, `signoffBrief`,
   `getCredits`, …).

---

## 9. Worked example — `GET /.agentic/build/{caseId}/{trackerId}`

Mirrors the human tracker view at `/build/{caseId}/{trackerId}`. Returns the
tracker's metadata, fields, statuses, a per-status record count, and the actions
to add records/fields, configure, rename, and delete the tracker. (Backing API
routes confirmed in `apps/api/src/router.rs` lines 107–136.)

```json
{
  "@context": "https://sojourn.cross-fare.com/agents.dir/context.jsonld",
  "@type": "Tracker",
  "@id": "/.agentic/build/5f1c8a20-0000-4000-8000-0000000000e9/9b2d7f10-0000-4000-8000-000000000077",

  "data": {
    "caseId": "5f1c8a20-0000-4000-8000-0000000000e9",
    "trackerId": "9b2d7f10-0000-4000-8000-000000000077",
    "name": "Press & Media",
    "color": "#3b82f6",
    "preset": "media",
    "fields": [
      { "id": "f-name",   "name": "Outlet",      "type": { "code": "text",   "label": "Text" } },
      { "id": "f-date",   "name": "Published",   "type": { "code": "date",   "label": "Date" } },
      { "id": "f-tier",   "name": "Tier",        "type": { "code": "select", "label": "Select" },
        "options": ["national", "trade", "local"] },
      { "id": "f-url",    "name": "Link",        "type": { "code": "link",   "label": "Link" } }
    ],
    "statuses": ["lead", "contacted", "confirmed", "published"],
    "countByStatus": {
      "lead": 4,
      "contacted": 2,
      "confirmed": 1,
      "published": 6
    },
    "recordCount": 13
  },

  "_links": {
    "self": {
      "href": "/.agentic/build/5f1c8a20-0000-4000-8000-0000000000e9/9b2d7f10-0000-4000-8000-000000000077",
      "title": "Press & Media tracker",
      "type": "application/vnd.sojourncase.agentic+json"
    },
    "up": {
      "href": "/.agentic/build/5f1c8a20-0000-4000-8000-0000000000e9",
      "title": "Build board",
      "type": "application/vnd.sojourncase.agentic+json"
    },
    "case": {
      "href": "/.agentic/case/5f1c8a20-0000-4000-8000-0000000000e9",
      "title": "Case surface"
    },
    "records": {
      "href": "/.agentic/build/5f1c8a20-0000-4000-8000-0000000000e9/9b2d7f10-0000-4000-8000-000000000077/records",
      "title": "All records in this tracker"
    },
    "record": {
      "href": "/.agentic/build/5f1c8a20-0000-4000-8000-0000000000e9/9b2d7f10-0000-4000-8000-000000000077/records/{recordId}",
      "title": "One record sheet",
      "templated": true
    },
    "describedby": {
      "href": "/openapi.json",
      "title": "OpenAPI document"
    }
  },

  "actions": [
    {
      "name": "addRecord",
      "title": "Add record",
      "method": "POST",
      "href": "/api/cases/5f1c8a20-0000-4000-8000-0000000000e9/trackers/9b2d7f10-0000-4000-8000-000000000077/records",
      "contentType": "application/json",
      "fields": [
        { "name": "id", "type": "string", "required": false,
          "description": "Client-supplied UUID; server mints one if absent or malformed." }
      ]
    },
    {
      "name": "addTrackerField",
      "title": "Add field",
      "method": "POST",
      "href": "/api/cases/5f1c8a20-0000-4000-8000-0000000000e9/trackers/9b2d7f10-0000-4000-8000-000000000077/fields",
      "contentType": "application/json",
      "fields": [
        { "name": "name", "type": "string", "required": true,
          "description": "Column header for the new field." },
        { "name": "type", "type": "string", "required": true,
          "enum": ["text", "date", "select", "link"],
          "description": "Field type (code)." },
        { "name": "id", "type": "string", "required": false,
          "description": "Client-supplied UUID; server mints one if absent." }
      ]
    },
    {
      "name": "updateTracker",
      "title": "Rename / recolor tracker",
      "method": "PATCH",
      "href": "/api/cases/5f1c8a20-0000-4000-8000-0000000000e9/trackers/9b2d7f10-0000-4000-8000-000000000077",
      "contentType": "application/json",
      "fields": [
        { "name": "name",  "type": "string", "required": false,
          "description": "New tracker name." },
        { "name": "color", "type": "string", "required": false,
          "description": "New hex color." }
      ]
    },
    {
      "name": "configureTracker",
      "title": "Configure statuses / preset",
      "method": "PUT",
      "href": "/api/cases/5f1c8a20-0000-4000-8000-0000000000e9/trackers/9b2d7f10-0000-4000-8000-000000000077/configure",
      "contentType": "application/json",
      "fields": [
        { "name": "statuses", "type": "array", "required": false,
          "description": "Replacement ordered list of status labels." }
      ]
    },
    {
      "name": "deleteTracker",
      "title": "Delete tracker",
      "method": "DELETE",
      "href": "/api/cases/5f1c8a20-0000-4000-8000-0000000000e9/trackers/9b2d7f10-0000-4000-8000-000000000077",
      "contentType": "application/json",
      "fields": []
    }
  ],

  "meta": {
    "kind": "tracker",
    "version": "agentic-envelope/1.0",
    "generatedAt": "2026-06-25T14:03:00Z",
    "humanUrl": "https://sojourn.cross-fare.com/build/5f1c8a20-0000-4000-8000-0000000000e9/9b2d7f10-0000-4000-8000-000000000077",
    "caseId": "5f1c8a20-0000-4000-8000-0000000000e9",
    "docs": {
      "openapi": "/openapi.json",
      "reference": "/agents.dir/api-reference.md",
      "endpoints": {
        "self":            "/agents.dir/api-reference.md#agentic-tracker",
        "up":              "/agents.dir/api-reference.md#agentic-build",
        "case":            "/agents.dir/api-reference.md#agentic-case",
        "records":         "/agents.dir/api-reference.md#agentic-tracker-records",
        "record":          "/agents.dir/api-reference.md#agentic-record",
        "describedby":     "/openapi.json",
        "addRecord":       "/agents.dir/api-reference.md#addrecord",
        "addTrackerField": "/agents.dir/api-reference.md#addtrackerfield",
        "updateTracker":   "/agents.dir/api-reference.md#updatetracker",
        "configureTracker":"/agents.dir/api-reference.md#configuretracker",
        "deleteTracker":   "/agents.dir/api-reference.md#deletetracker"
      }
    }
  }
}
```

Note how the example satisfies all three invariants: every `_links.href` is an
`/.agentic/*` GET; every `actions[].href` is a real `/api/*` verb; and
`meta.docs.endpoints` has exactly one key for each of the five `_links` rels
(`self`, `up`, `case`, `records`, `record`, `describedby`) and each of the five
action names (`addRecord`, `addTrackerField`, `updateTracker`, `configureTracker`,
`deleteTracker`).
