Subjects and escalations
What each case is about, which records relate to it, and the typed hand-offs a pack names for cases it can't decide.
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) |
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 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.
{
"name": "counterparty",
"subject": "company",
"cardinality": "one",
"required": true,
"definition": "<quote: what the counterparty is>"
}
cardinalityisone(a ticket has one customer) ormany(a contract’s affiliates). Formany, an agent sends a list of IDs and names, on each excerpt, which record it comes from.required: truemeans the policy says a decision needs that record.definitionquotes 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:
{
"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:
"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:
{ "status": "gap", "result": { "outcome": null, "missing": ["missing: counterparty"], "receipt": null, "...": "..." } }
The request still counts as an attempt. 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:
"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:
"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.
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:
{ "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.
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 }:
"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 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. 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.
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 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: the exact fields.
- The eleven categories: subjects and relationships is the eleventh.
- Retries and attempts: how escalations end a case.