---
title: Subjects and escalations
description: What each case is about, which records relate to it, and the typed hand-offs a pack names for cases it can't decide.
sidebar:
  order: 5
---

A pack says two things about every case besides its outcome: **what the case is about**, and **where it goes when it can't be decided**. Both come from the policy document, quoted like every other meaning in the pack. Felspar assumes no line of business: a case is a ticket only because your document says so.

## Subjects

A **subject** is the kind of record one case is about: a support ticket, a contract, a candidate, a marketing claim, a company. A pack declares each kind of record its policy talks about once, under `subjects`, and names the job's own in `subject`.

| Field | What it says |
| --- | --- |
| `id` | The record's name in the document, such as `ticket`, `contract` or `candidate` |
| `definition` | The quote that says what the record is: "A ticket is one request a customer sends to the help desk." |
| `identifier` | How its ID is written: "help desk ticket number", "contract ID", "web domain". Needed when cases use the record's own ID, and for any subject a relationship points to. |
| `example` | One ID, written the way Felspar keeps it, such as `48213` or `northwind.com`. Needed with `identifier`. |
| `match` | `exact` (the default) or `case-insensitive`, for IDs such as web domains and email addresses |
| `identity` | How a case gets its ID when it has none of its own (see [identity modes](#identity-modes)) |
| `relationships` | The other records a case links to (below) |

### One case, one ID

Felspar normalizes every case ID by the job subject's `match` rule before it counts attempts, keeps history or writes a receipt. `exact` trims spaces; `case-insensitive` trims and lowercases. So `Northwind.com` and `northwind.com` are one case, while ticket `HC-1201` and `hc-1201` are two. A case ID uses letters, digits and `. _ : @ -`, up to 200 characters; anything else is refused with `subject_id_invalid`.

A `felspar.pack-draft/v0` or `v1` job without a subject takes the ID the agent sends and matches it exactly. A v2 job without a subject gets [issued](#identity-modes) case IDs.

The brief (`get_job_requirements`) leads with the subject, and its `subject.send` says in plain words exactly which ID to send for a case and, for related records, how to send theirs. Its `identity` says how the case itself is identified (below).

## Relationships

A relationship links the job's subject to another declared subject: a ticket's customer, a contract's counterparty, a contract's affiliates.

```json
{
  "name": "counterparty",
  "subject": "company",
  "cardinality": "one",
  "required": true,
  "definition": "<quote: what the counterparty is>"
}
```

- **`cardinality`** is `one` (a ticket has one customer) or `many` (a contract's affiliates). For `many`, an agent sends a list of IDs and names, on each excerpt, which record it comes from.
- **`required: true`** means the policy says a decision needs that record.
- **`definition`** quotes what the relationship is.

A question whose evidence comes from a related record says so with `about`, such as `"about": "counterparty"` on a governing-law question. The agent then sends the related record's ID under `related` and quotes that record:

```json
{
  "jobId": "review-nda",
  "entityId": "CTR-2026-0117",
  "related": { "counterparty": "Northwind.com", "affiliates": ["contoso.com", "fabrikam.com"] },
  "facts": {
    "law": { "text": "<the contract's governing-law clause>", "source": { "...": "..." } },
    "sanctions": [
      { "text": "<registry entry for Contoso>", "source": { "...": "..." }, "about": "contoso.com" },
      { "text": "<registry entry for Fabrikam>", "source": { "...": "..." }, "about": "fabrikam.com" }
    ]
  }
}
```

Each related ID is normalized by its own subject's `match` rule (`Northwind.com` becomes `northwind.com` above). The result records which related records supplied evidence for which questions:

```json
"subject": {
  "type": "contract",
  "id": "CTR-2026-0117",
  "related": [
    { "relationship": "counterparty", "subject": "company", "id": "northwind.com", "questions": ["law"] },
    { "relationship": "affiliates", "subject": "company", "id": "contoso.com", "questions": ["sanctions"] },
    { "relationship": "affiliates", "subject": "company", "id": "fabrikam.com", "questions": ["sanctions"] }
  ]
}
```

### A missing required record is a gap

When the job asks a question about a required relationship and the request has no record or no evidence for it, Felspar decides nothing. The result has status `gap`, no outcome and no receipt, and `missing` names what to find:

```json
{ "status": "gap", "result": { "outcome": null, "missing": ["missing: counterparty"], "receipt": null, "...": "..." } }
```

The request still counts as an [attempt](/guides/retries-and-attempts). The agent finds that record and sends its ID and quotes, or escalates.

## Escalations

An **escalation** is a hand-off the policy names: `human-review`, `legal-review`, `compliance-review`, `hiring-manager-review`. A pack lists them under `escalations`, each with the quote that defines it, and points to them from three places:

| Where | When the result carries it | `trigger` |
| --- | --- | --- |
| `outcomes[].escalate` | That outcome was decided | `outcome` |
| `whenUnresolved: { quote, escalate }` | Nothing was decided (including a gap) | `unresolved` |
| `attempts.escalate` | The case has used its attempts (falls back to the unresolved one) | `attempt_limit` |

A result that carries one looks like this:

```json
"escalation": {
  "id": "incident-review",
  "trigger": "outcome",
  "why": { "text": "Incident review means the incident manager takes ownership of the ticket." }
}
```

A request refused with `attempt_limit_reached` carries the attempt limit's escalation too.

**Felspar names the escalation and stops.** It never pages anyone, opens a ticket or calls a tool. Your own code maps each escalation ID to what it does: a queue, a channel, a person. Name escalations by the kind of review, never by a tool, channel or person, so the mapping stays yours. Receipt verification re-derives the subject and escalation from the release, so the code that acts can trust them.

## Changing a pack without breaking callers

Code that acts on results reads escalation IDs, subject types and relationship names. When a proposal removes or renames one, `check_pack`, `propose_pack` and `backtest` list it under `breaking`, with how many agents run the job:

```json
"breaking": {
  "changes": ["removes escalation compliance-review", "removes relationship contract.matter"],
  "agents": 1
}
```

A changed job subject is listed too. Report every breaking change to the people who own those agents before the release. See [Review and backtest](/guides/review-and-backtest).

## Worked shapes

The same engine serves every one of these; only the pack's words differ. Quotes are left out.

| Work | Job | Subject | Relationships | Escalations |
| --- | --- | --- | --- | --- |
| Support desk | `triage-ticket` | `ticket`, exact, `48213` | `customer` (one, optional); the plan question is `about` it | `human-review` for unresolved cases and the attempt limit; `incident-review` on the on-call outcome |
| NDA review | `review-nda` | `contract`, exact, `CTR-2026-0117` | `counterparty`: company (one, required); `affiliates`: company (many); `matter` (one) | `legal-review`, `compliance-review` |
| Candidate screening | `screen-candidate` | `candidate`, case-insensitive, by email address | `requisition` (one) | `hiring-manager-review` |
| Sales qualification | `qualify-company` | `company`, case-insensitive, by web domain | none | `sales-review` for the review outcome and the unresolved path |

## Identity modes

Not every case has an ID of its own. The job subject's `identity` says how a case gets one:

| Mode | The case is | The agent sends | Case ID |
| --- | --- | --- | --- |
| `provided` | A record with its own ID, as above. The default when the subject has an `identifier`. | `entityId` (`entity_id` over MCP): the record's ID | The ID, normalized by `match` |
| `fields` | Built from named values the job collects, such as a candidate's email address and a requisition number | Each value by name under `identity`, and no `entityId` | `fields-` and a hash of the values, each normalized by its own match rule, in name order, so the order they're sent in never matters |
| `content` | The text being judged, such as a marketing claim or a draft email | The text under the question the identity names, and no `entityId` | `content-` and a hash of that question's excerpts, so the same text is the same case |
| `issued` | Something with no ID at all, such as an anonymous chat inquiry. The default for a subject without an `identifier`, and for a v2 job without a subject. | No `entityId` on the first decision; then the returned `case.id` | `case-` and 32 hex characters, issued to this caller for this job |

In a pack draft:

```json
{ "id": "candidate", "definition": "<quote>", "match": "case-insensitive",
  "identity": { "mode": "fields", "fields": [
    { "name": "email", "identifier": "the candidate's email address", "example": "sam@example.com", "match": "case-insensitive" },
    { "name": "requisition", "identifier": "the requisition number", "example": "REQ-114" }
  ] } }
```

`{ "mode": "content", "question": "claim" }` names the question whose text is the case, and `{ "mode": "issued" }` needs nothing else. See [Pack draft format](/reference/pack-draft-format#identity-modes).

The brief's `identity` gives the `mode`, `send` (in plain words, what to send), and the `fields` or `question` it uses. Its `input.jsonSchema` for `get_job_decision` follows the mode: `entity_id` is required only for `provided`, and offered for `issued`, and `identity` appears only for `fields`.

### The case in every result

Each result of a job released with identity modes carries `case: { id, mode }`:

```json
"case": { "id": "case-3f9a0c4e1b7d42a8964e0d5c2f1a7b3e", "mode": "issued" }
```

Reuse `case.id` for every later request about the same case: retries, and actions that name the case. ([`check_output`](/reference/mcp-tools#check_output) needs no case ID: it goes by the decision's execution ID.) For `fields` and `content`, sending it back as `entityId` is accepted; any other ID is refused with `case_id_derived`. For `issued`, an ID Felspar didn't issue to this caller for this job is refused with `unknown_case_id`. Receipt verification recomputes the case from the retained request and returns the same `case` under `decided`.

### Issued IDs

An issued ID belongs to one caller and one job. A first decision whose excerpts are exactly the same text as a case the caller opened on the job in the last 24 hours reuses that case, so starting over doesn't reset its [attempt limit](/guides/retries-and-attempts). A caller may open at most 200 new issued cases per job in any 24 hours; past that, a first decision is refused with `issued_case_limit`. Reuse the `case.id` you're working on instead. The full list of identity refusals is in [Refusal codes](/reference/refusal-codes#identity-refusals).

Packs released before identity modes keep working unchanged: their results carry no `case`, and the agent sends its own ID.

### Acting on the case

The [pre-action gate](/guides/pre-action-gate) in `@felspar/sdk` supports issued and derived case IDs from SDK 0.2.1. It records each decision by its `case.id`, even when the agent sent no `entity_id`, and takes the case from the job's subject by default.

## Limits

| Limit | Value |
| --- | --- |
| Subject types per pack | 16 |
| Relationships per subject | 16 |
| Escalations per pack | 16 |
| Related IDs per request | 16 |
| Case ID | 1 to 200 characters: letters, digits and `. _ : @ -` |

## Related

- [Pack draft format](/reference/pack-draft-format): the exact fields.
- [The eleven categories](/concepts/categories): subjects and relationships is the eleventh.
- [Retries and attempts](/guides/retries-and-attempts): how escalations end a case.
