# LedgerCovenant API

Hosted covenant decision engine for credit facilities. Authenticated on every route except the
Stripe webhook.

Base path: `/api/ledgercovenant/v1`

Request bodies accept camelCase or snake_case. Every refusal carries a stable `reason_code`, and
where a human can act on it, the competing candidates come back in `detail` — a 422 that says only
"unprocessable" forces you back to us to find out what to fix.

**What extraction here does and does not mean.** There is one automated backend: a deterministic
pattern matcher that proposes candidate spans through
`POST /documents/{versionId}/extract/suggest`. It writes nothing, it has no model in it, and its
candidates carry the precision it actually measured against your analyst-tagged spans or 0.0. A span
becomes evidence only through the analyst path, which verifies the offsets against the text you
submit and records who tagged it. `GET /policies` reports what is wired and what it has been
measured at.

---

## Authentication

Either a signed-in session:

```
Authorization: Bearer <InsightITS JWT>
```

or a machine credential:

```
X-API-Key: lcv_...
```

Same JWT as the rest of the InsightITS dashboard. Every row is scoped to the authenticated
`user_email`, and within an account, facilities can be further walled by deal team (see
`/access/grants`).

### API keys and scopes

`POST /keys` issues a key. It is shown once, stored as a SHA-256 digest, and its issuance and
revocation are both entries on the evidence ledger — "when was that key created and by whom" is an
audit question, and answering it from a mutable table is answering it on trust.

```json
{
  "label": "quarterly monitoring job",
  "scopes": ["read", "replay"],
  "facilityIds": ["lcfac_..."],
  "expiresAt": "2027-01-01T00:00:00Z"
}
```

| Scope | Grants |
| --- | --- |
| `read` | Facilities, obligations, decisions, baskets, breach timelines, policies |
| `decide` | Run and finalize decisions, derivations, basket events, breach transitions |
| `replay` | Tier 1 and Tier 2 replay |
| `evidence` | Export and retain evidence packs |
| `anchor` | Create checkpoints and anchor them with the timestamp authority |

Broader scopes imply `read`, so a `decide` key can read the decision it just made.

**There is no `attest` scope, and there will not be one.** An attestation is a named person taking
responsibility for a statement about a credit agreement, and it is the artifact that lets a decision
proceed over a gap in the documents. A credential on a CI runner signing one would make the
strongest evidence in the system the easiest to produce. Asking for `attest`, `supersede`, or
`admin` is refused at issuance rather than at use, so the mistake surfaces while someone is still
reading. These require a signed-in user:

`POST /attestations`, `POST /attestations/{id}/revoke`, `POST /decisions/{id}/supersede`,
`POST /keys`, `POST /keys/{id}/revoke`, `POST /teams`, `POST /teams/{id}/members`,
`POST /access/grants`, and the billing routes. A key reaching one of them is refused
`AUTH_KEY_SCOPE` with that reason stated.

`facilityIds` pins a key to specific facilities. The pin is intersected with the deal-team wall
rather than replacing it — it can only ever narrow what its holder could already see — and it is
enforced wherever a facility is resolved, including routes keyed on a decision or basket id.
Outside the pin, `AUTH_KEY_FACILITY`.

`GET /keys` lists keys with their scopes, expiry, last use, and revocation state. Digests are never
returned and neither is the key.

---

## Ordering

The engine will not invent a facility, a term, or a threshold to keep a request moving. Set up in
this order:

1. `POST /facilities`
2. `POST /facilities/{id}/documents` — the credit agreement, then each amendment
3. `POST /facilities/{id}/terms` and `POST /facilities/{id}/terms/{term_id}/versions`
4. `POST /facilities/{id}/obligations` — with the threshold schedule
5. `POST /facilities/{id}/facts` — one call per source, per period
6. `POST /decide/preview`, then `POST /decide`

---

## POST /facilities

```json
{
  "borrowerName": "Northwind Manufacturing Holdings, LLC",
  "facilityName": "Senior Secured Term Loan B",
  "baseCurrency": "USD",
  "wallClass": "PRIVATE_SIDE"
}
```

`wallClass` is `PRIVATE_SIDE`, `PUBLIC_SIDE`, or `CROSSING`. Optional `restrictedGroup` records the
restricted-list context. Deal-team access is resolved as-of the date being asked about, so a member
who joined in March does not retroactively gain visibility into January.

**201** returns `facility_id`. Refuses `TIER_LIMIT` when the plan's facility limit is reached.

---

## POST /facilities/{facility_id}/documents

Register a document version and the spans your analyst tagged.

```json
{
  "docType": "amendment",
  "title": "Amendment No. 2 to Credit Agreement",
  "executedOn": "2026-03-14",
  "parties": ["Northwind Manufacturing Holdings, LLC", "Meridian Credit Partners, L.P."],
  "documentText": "... the full extracted text of the amendment ...",
  "pageCount": 41,
  "spans": [
    {
      "charStart": 18422,
      "charEnd": 18511,
      "text": "Consolidated EBITDA shall be calculated giving effect to add-backs not exceeding 20%",
      "page": 12,
      "labels": { "kind": "term_definition", "term": "consolidated ebitda" }
    }
  ]
}
```

`docType` is one of `credit_agreement`, `amendment`, `amendment_and_restatement`, `waiver`,
`side_letter`, `compliance_certificate`, `financials`, `correspondence`, `other`. It determines
document authority in the
precedence ladder — and that rank is recomputed from the active policy at resolution time rather
than frozen at write time, so pinning a precedence policy version actually changes the ranking on
replay.

`executedOn` matters: a document governs from execution, not from upload. Registering the 2024
amendment today must not make it look like it took effect today.

**Document text is not stored.** It is used to verify every span offset and to compute
`content_hash`, then dropped. Keep your copy: Tier 2 diagnostic replay and independent span
verification both need it. If the quoted `text` does not match the characters at those offsets, the
call is refused `INPUT_INVALID` — that is usually offsets pasted from a version that reflowed.

**201** returns `document_id`, `version_id`, `content_hash`, `authority_rank`, and a `span_id` per
span. `text_retained` is `false`.

---

## POST /facilities/{facility_id}/terms

```json
{ "displayName": "Consolidated EBITDA" }
```

Idempotent on the normalized name, so `Consolidated EBITDA` and `consolidated  ebitda` are the same
term rather than two that silently diverge.

## POST /facilities/{facility_id}/terms/{term_id}/versions

One definition, with its validity window and what it references.

```json
{
  "definitionText": "Consolidated EBITDA means ... plus add-backs not exceeding 20% ...",
  "validFrom": "2026-03-14",
  "validTo": null,
  "spanId": "lcspan_...",
  "operation": "AMEND",
  "amendsVersionId": "lctermv_...",
  "specificity": 2,
  "references": [
    { "termId": "lcterm_...", "refType": "USES" },
    { "termId": "lcterm_...", "refType": "CAP" }
  ]
}
```

`refType` is `USES`, `CAP`, or `EXCLUDES`. The distinction is load-bearing: a cycle through a `CAP`
edge is a cap (legitimate — a definition bounded by a figure that itself references the
definition), while a cycle through `USES` edges is a circular definition and is refused
`LEGAL_CIRCULAR_DEFINITION`.

`GET /terms/{term_id}/history` returns the amendment lineage.
`GET /terms/{term_id}/blast-radius?period=2026-Q2` returns every obligation whose meaning depends on
this term on that date. Impact depends on the date, so the date is required.

---

## POST /facilities/{facility_id}/obligations

```json
{
  "obligationId": "lcobl_total_net_leverage",
  "obligationType": "RATIO",
  "comparator": "lte",
  "numeratorTermId": "lcterm_consolidated_total_debt",
  "denominatorTermId": "lcterm_consolidated_ebitda",
  "testFrequency": "quarterly",
  "validFrom": "2026-03-14",
  "operation": "AMEND",
  "amendsVersionId": "lcoblv_...",
  "spanId": "lcspan_...",
  "specificity": 3,
  "thresholds": [
    { "validFrom": "2026-01-01", "validTo": "2026-12-31", "valueScaled": 4500000, "scale": 6 },
    { "validFrom": "2027-01-01", "validTo": null, "valueScaled": 4000000, "scale": 6 }
  ]
}
```

Pass `obligationId` to add a new version of an existing obligation; omit it to create a new one.

`obligationType` is `RATIO` or `ABSOLUTE`. `comparator` is `lte`, `lt`, `gte`, `gt`, or `eq`. A
`RATIO` obligation must have a denominator term. `testFrequency` is `quarterly`, `monthly`,
`annual`, or `incurrence`.

Thresholds are scaled integers, never floats. `4500000` at `scale: 6` is 4.50x. A gap in the
schedule on a test date is refused `THRESHOLD_MISSING` rather than filled by interpolation, because
guessing at the step-down is not something a covenant test may do.

---

## POST /facilities/{facility_id}/facts

What one source reports. Call it once per source; the engine resolves competition.

```json
{
  "factKey": "consolidated ebitda",
  "period": "2026-Q2",
  "value": "24500000",
  "scale": 6,
  "currency": "USD",
  "sourceType": "audited_financials",
  "spanId": "lcspan_...",
  "groundingConfidence": 1.0
}
```

`value` is an integer or a decimal string. A float is refused rather than rounded — a
representation that cannot round-trip has no place in an evidence payload.

`sourceType` positions the assertion in the versioned source lattice (`lattice_v1`, higher wins):

| Source | Rank | |
| --- | --- | --- |
| `compliance_certificate` | 100 | Officer-signed; the contractual representation |
| `audited_financials` | 90 | Audited statements |
| `reviewed_financials` | 70 | Reviewed, not audited |
| `management_accounts` | 50 | Unaudited management figures |
| `system_export` | 40 | Portfolio system export |
| `analyst_override` | 30 | Analyst-entered value with justification |
| `correspondence` | 20 | Email or letter |
| `attestation` | floor | Admitted below every document source |

The certificate outranking the audited statements is deliberate: the covenant is tested against what
the borrower contractually represented. The audited figure is not discarded — it is recorded as a
competing candidate and visible in provenance.

Two sources of **equal** rank reporting different values is `FACT_CONFLICT` — a question for an
analyst, not an average and not "take the newer one". A corrected compliance certificate filed after
an audit adjustment is the common case, and it stops the pipeline on purpose.

Two independent equal-rank sources that *agree* raise confidence through corroboration, reported
separately rather than folded silently into the number.

`GET /facilities/{id}/facts/provenance?factKey=...&period=...` shows every competing assertion, the
winner, and why.

---

## POST /facilities/{facility_id}/exceptions

Waivers, cures, and carve-outs.

```json
{
  "targetObligationId": "lcobl_total_net_leverage",
  "exceptionType": "WAIVER",
  "scopePeriod": "2026-Q2",
  "validFrom": "2026-07-20",
  "cureDays": 30,
  "spanId": "lcspan_..."
}
```

A waiver in force for the tested period turns a breach into `WAIVED` and says which instrument did
it. It does not erase the underlying arithmetic.

---

## POST /decide/preview

Same body as `/decide`. Evaluates and returns the full stage trace, gate outcome, and confidence
vector without consuming quota, writing a decision, or touching the ledger. Use it while setting a
facility up.

## POST /decide

```json
{
  "facilityId": "lcfac_...",
  "obligationId": "lcobl_total_net_leverage",
  "testPeriod": "2026-Q2",
  "knowledgeTime": null,
  "toleranceScaled": 0
}
```

`knowledgeTime` defaults to the end of the test period, not to now. The honest question is what the
documents on hand said when the period closed; defaulting to now would let a document filed months
later silently change a historical answer. Pass an explicit timestamp to ask "what did we believe in
April?"

Stages run in a fixed order, and each one appears in the response:

1. **Legal** — which obligation version governs. Precedence is amendment lineage, then temporal
   narrowness, then document authority, then specificity. More than one survivor is `AMBIGUOUS` with
   all candidates listed. It does not fall back to "most recent", and under the default policy
   winning on recording order alone is not permitted.
2. **Lineage** — the transitive closure of defined terms the governing provision depends on,
   resolved as-of, with cap-versus-cycle detection.
3. **Facts** — source-lattice resolution per required fact key.
4. **Threshold** — the schedule row in force on the test date.
5. **Calculation** — scaled-integer arithmetic. Division uses a pinned `Decimal` context, and the
   rounding mode travels in the trace.
6. **Gate** — a confidence vector over every material, then `ACCEPT`, `REVIEW`, or `REFUSE`.

**Gate outcomes.** `ACCEPT` signs a decision onto the hash-chained ledger and returns
`decision.decision_id`, `decision_payload_hash`, `signature`, `signing_key_id`, and `ledger_seq`.
`REVIEW` and `REFUSE` return no decision; they return `review_items`, each naming the specific
artifact to look at. A generic "confidence was low" item with nothing to inspect costs a reviewer
more time than it saves, which is why review queues get ignored.

Calling a breach is treated as more consequential than calling a pass: a breach on evidence that
would clear the bar for a compliant finding still routes to `REVIEW`.

Sandbox-plan decisions are signed with the marked development key and carry
`is_development_key: true`. They are not regulatory artifacts, deliberately.

---

## GET /decisions and GET /decisions/{decision_id}

Detail verifies the signature and the payload hash on every read, not only on explicit replay: a
decision pulled into a report should not look fine while its hash no longer matches.

## POST /decisions/{decision_id}/replay

```json
{ "tier": 1 }
```

**Tier 1 (audit-grade).** Rehydrates the exact assertion hashes the decision froze, then re-runs
resolution, fact weighting, and arithmetic under the policy versions pinned at decision time. It
never calls an extractor, precisely so a retired extractor cannot break a historical decision. Two
distinct failures, kept distinct:

- `REPLAY_MISSING_ARTIFACT` — a frozen input no longer hashes to what the decision recorded. The
  inputs moved.
- `REPLAY_MISMATCH` — the inputs are intact and the result differs. The engine regressed.

Collapsing those into one "mismatch" would throw away the only thing that tells you which of the two
happened.

**Tier 2 (diagnostic).** Requires `documents` — a map of `version_id` to the document text, because
the text was never stored. Each version's text is hash-checked against what the decision relied on;
a mismatch *is* the finding. The characters at every tagged offset are then re-read, and with
`reExtract` (default `true`) the automated extractor is run over the verified text and compared to
the spans the decision rests on. Tagged evidence today's extractor would no longer propose comes
back in `extraction_drift.drifted_span_ids`.

Tier 2 writes nothing — the response says `wrote_nothing: true` — and extractor drift is a statement
about the extractor, never about the decision, which rests on the analyst's tag either way.

Tier 1 requires the Analyst plan or above; Tier 2 requires Desk or above.

## POST /decisions/{decision_id}/supersede

Corrections happen by supersession, never by editing.

```json
{
  "reasonCode": "RESTATED_FINANCIALS",
  "narrative": "Auditor restated Q2 EBITDA following the inventory adjustment described in ...",
  "attestationId": "lcatt_...",
  "changedFields": { "consolidated ebitda": "24500000 -> 23900000" }
}
```

The replacement is recomputed from evidence, not supplied by the caller — accepting a caller-named
replacement would let the chain point at a decision that was never computed. The prior decision
stays readable and both appear in the evidence pack. Requires `ATTEST` access and a valid
attestation. If the corrected run does not pass the gate, the call refuses `GATE_REFUSED` and
returns the review items instead of superseding with nothing.

---

## GET /facilities/{facility_id}/evidence-pack

Optional `?obligationId=` and `?period=` narrow the scope.

The pack is designed to be verifiable by someone who does not trust this server and does not have
this code. It carries:

- the Ed25519 public key and key id, flagged if it is a development key
- the ledger from genesis with the chain verification result
- every decision payload, signature, and stage trace
- supersessions and attestations
- documents (hashes only), spans, and the review-item history
- the source lattice actually used
- **every policy version inlined as data**, not referenced by name — a pack that says
  `precedence_policy_v1` and nothing else is unverifiable in five years
- RFC 8785 canonicalization conformance vectors, so a verifier can confirm its own JSON serializer
  before trusting any hash here; without them a mismatch is unattributable
- `durability` — whether checkpoints are externally timestamped and whether the pack itself is
  retained under Object Lock on this deployment, with the anchor records if so
- `verification_instructions`, and a section stating plainly what the pack does **not** prove

Requires the Analyst plan or above.

## POST /facilities/{facility_id}/evidence-pack/retain

Builds the pack and writes it to an S3 bucket with Object Lock, so it survives us as well as our
edits. The ledger proves nothing was altered; it cannot prove nothing was destroyed, and in a
dispute those are different questions.

```json
{ "obligationId": "lcobl_...", "period": "2026-Q2" }
```

Retention defaults to **2,555 days (seven years)**, not one: contract limitation periods run to six
years in most relevant jurisdictions, and a covenant dispute over a 2026 test date can plausibly be
litigated in 2032. Retention that expires before the claim does is retention theatre. A configured
value below 365 days is raised to that floor.

The default lock mode is `COMPLIANCE`, which no principal in the account can bypass, including the
root user. `GOVERNANCE` is accepted for operators who cannot take on unpayable storage from a
mistaken write, and it is genuinely weaker — `s3:BypassGovernanceRetention` defeats it — so the mode
travels with every retained object and `GET /policies` says so rather than letting the word "WORM"
cover both.

Two refusals rather than a fallback:

- `WORM_NOT_CONFIGURED` — no bucket is wired. Nothing is written elsewhere.
- `WORM_LOCK_UNAVAILABLE` — the bucket has no Object Lock, so a write there would not be write-once.
  Object Lock can only be enabled at bucket creation, and the message says so.

`put_object` returning 200 is not taken as proof the lock applied: the object is read back and the
returned mode and retain-until date are recorded. When they do not come back, the response says
`lock_confirmed: false` and treats the retention as unconfirmed instead of asserting durability.
Object keys are never returned — the content hash is what proves the artifact.

`GET /worm/objects` lists what has been retained, with hashes, modes, and retain-until dates.

Requires Desk or above.

---

## POST /facilities/{facility_id}/derive

Settles a defined amount whose add-back caps reference the amount itself.

```json
{
  "factKey": "consolidated_ebitda",
  "period": "2026-Q2",
  "baseScaled": 100000000000000,
  "scale": 6,
  "components": [
    {
      "componentKey": "stock_comp",
      "capBasis": "NONE",
      "claimedScaled": 5000000000000,
      "assertionId": "lcasrt_..."
    },
    {
      "componentKey": "cost_savings",
      "capBasis": "POST_ADDBACK",
      "claimedScaled": 30000000000000,
      "capPercentScaled": 200000,
      "spanId": "lcspan_..."
    }
  ]
}
```

`baseScaled` is the uncapped starting figure — net income plus the mechanical add-backs that carry
no limit. `components` are the ones that do. Amounts are scaled integers; a float is refused with
`FLOAT_IN_EVIDENCE` rather than rounded.

**Cap bases.**

| `capBasis` | Cap measured against | Circular |
| --- | --- | --- |
| `NONE` | nothing; the claim is admitted in full | no |
| `ABSOLUTE` | `capAmountScaled`, a fixed ceiling | no |
| `PRE_ADDBACK` | `capPercentScaled` of the base | no |
| `POST_ADDBACK` | `capPercentScaled` of the derived total | yes |

`POST_ADDBACK` is the one that matters. "Cost savings not to exceed 20% of Consolidated EBITDA
(calculated after giving effect to such add-backs)" defines the amount in terms of itself, and with
several such caps interacting there is no closed form. The amount is therefore defined as a fixed
point and found by iterating upward from the uncapped base until two successive passes agree
exactly. The example above settles at $125m: 20% of $125m is $25m, which is below the $30m claimed,
so the cap binds at $25m and $100m + $25m = $125m.

Three things about that are worth stating plainly, because they are the difference between a
defensible number and a plausible one:

- **It is the least fixed point.** Iterating upward from the uncapped base reaches the
  borrower-conservative solution, which is the one "such add-backs" supports.
- **Convergence is exact integer equality.** No tolerance, so no machine-dependent stopping point.
- **A definition that does not settle is refused, not truncated.** Caps that hand back nearly the
  whole result creep rather than converge. When the declared budget (`derivation_policy_v1`, 1024
  passes) runs out the answer is `DERIVATION_NO_CONVERGENCE` with the last iterates attached —
  reporting the final iterate would be inventing a limit the document does not contain.

A percentage cap with no percentage tagged, or an absolute cap with no ceiling, is
`DERIVATION_CAP_UNDEFINED`. It is never defaulted to uncapped: silently admitting an add-back in
full because nobody recorded its limit is the failure this product exists to prevent.

The response carries `resultScaled`, `iterations`, `bindingCaps`, and a trace holding the full
iterate sequence plus the component-by-component detail of the pass that settled. `GET
/derivations/{derivation_id}` returns a stored run.

`selfFinancingCaps: true` is an observation, not a refusal: `POST_ADDBACK` percentages summing to
100% or more of the result barely constrain anything, so the claimed amounts end up governing. The
value is still finite because each add-back is bounded by what was claimed — but caps that do not
constrain are usually a tagging error, so it is surfaced.

---

## Baskets

Builder and basket capacity, folded from events. **There is no stored balance**, by design: a
cached balance is a second source of truth that disagrees with the events the first time one is
restated, and the cache is what the report reads.

- `POST /facilities/{id}/baskets` — `basketKey`, `displayName`, `basketKind` (`BUILDER`, `FIXED`,
  `GROWER`, `RATIO_BASED`)
- `GET /facilities/{id}/baskets` — the facility's baskets
- `POST /baskets/{basket_id}/events` — append one event
- `GET /baskets/{basket_id}/events` — the raw events
- `GET /baskets/{basket_id}?asOf=2026-06-30&knowledgeTime=...` — capacity

```json
{
  "eventType": "USE",
  "amount": "20000000",
  "effectiveOn": "2026-05-15",
  "period": "2026-Q2",
  "narrative": "Q2 dividend under section 7.06(a)"
}
```

Event types are `GRANT` and `RESTORE` (capacity in), `USE` and `EXPIRE` (capacity out), and `RESET`
(zeroed, then rebuilt by later grants — an annual basket is modelled this way so the reset is a
visible, dated act rather than arithmetic on a period boundary). Amounts are unsigned; direction
comes from the event type, because a negative `GRANT` is a `USE` in disguise and would not appear in
a report of usages.

`asOf` and `knowledgeTime` are different questions and neither defaults to the other. `asOf` is when
it happened; `knowledgeTime` is when we knew. A restated event changes today's answer without
changing what the deal team saw in April. The fold is ordered by `(effectiveOn, recordedAt,
eventId)`, so two servers and two replays agree on which same-day usage was the one that overdrew.

A `USE` is checked against capacity **as of its own date**, not today: a dividend paid in March is
tested against the basket as it stood in March. Insufficient capacity refuses
`BASKET_INSUFFICIENT` with the available amount and the shortfall. Pass `allowOverdraw: true` to
record a payment the borrower actually made with no capacity behind it — that happens, and a system
that refuses to represent it pushes the truth into a spreadsheet. The event is flagged `overdrawn`
so nobody records one by accident.

---

## Breach lifecycle

A test result is a moment; a breach is a process, and disputes are far more often about the timeline
than about the ratio.

- `GET /facilities/{id}/breach/{obligation_id}?period=2026-Q2&asOf=...` — projected state
- `POST /facilities/{id}/breach/{obligation_id}/transition` — append one step

```json
{
  "testPeriod": "2026-Q2",
  "toState": "CURE_PERIOD",
  "effectiveOn": "2026-08-20",
  "cureDays": 30,
  "narrative": "Cure period commenced on service of the notice dated 20 August."
}
```

States are `BREACH_IDENTIFIED`, `NOTICE_GIVEN`, `CURE_PERIOD`, `DISPUTED`, and the terminal
`CURED`, `WAIVED`, `DEFAULT_ASSERTED`. Transitions are a closed map, published at `GET /policies`,
and checked on every append: a cure cannot appear without a breach, and a default cannot be asserted
before a cure period has started. A breach can be waived or cured without notice ever being served,
because lenders routinely resolve quietly. The state is projected from the events, so the timeline
cannot be rewritten by flipping a status column, and every transition is mirrored onto the
hash-chained ledger as a `BREACH_EVENT` — a notice date that can be quietly backdated is worth
nothing in the only situation where any of this matters.

**The system does not declare an event of default.** It reports `cureWindowExpired: true`, which is
arithmetic about a date, and leaves the state at `CURE_PERIOD`. Nothing auto-advances.
`NOTICE_GIVEN`, `CURED`, `WAIVED`, and `DEFAULT_ASSERTED` are acts by a person and each requires a
signed, verified, unrevoked attestation naming who took it; without one the call refuses
`ATTESTATION_INVALID`. `cureDays` is calendar days from the notice date — when an agreement counts
business days, pass `cureDeadline` explicitly rather than letting the server guess a holiday
calendar it does not have.

Requires `ATTEST` access.

---

## Review queue

`GET /review?facilityId=&status=open` lists open items. Kinds are `LEGAL_AMBIGUOUS`,
`LEGAL_UNRESOLVED`, `FACT_CONFLICT`, `FACT_MISSING`, and `LOW_CONFIDENCE`. Legal ambiguities and
conflicting figures route to different queues so they land in front of the right reviewer.

`POST /review/{item_id}/resolve` takes `attestationId`, or `dismiss: true`. Closing an item is
itself ledgered — the resolution is evidence, not housekeeping.

---

## Attestations

`POST /attestations` records a signed human statement as a first-class evidence artifact: a verbal
side agreement, an unindexed schedule, a judgement call on an ambiguity. It expires, it names the
attestor, and it enters the source lattice at the floor, below every document source. Decisions that
rest on an attestation are flagged as such in the pack.

`GET /attestations/{id}/verify` and `POST /attestations/{id}/revoke` are also available. Authority to
attest is checked as-of the scope period, not as-of now.

---

## Ledger

- `GET /ledger/head` — current sequence and head hash
- `GET /ledger/entries` — the chain, paginated
- `GET /ledger/verify` — walk from genesis; detects altered payloads, removed entries, and broken
  links, and reports the sequence where the chain first breaks
- `POST /ledger/checkpoint` — sign a checkpoint over the current head (Desk and above)
- `GET /ledger/anchors` — the external timestamps recorded against your checkpoints

Sequence numbers are contiguous by construction, allocated under a row lock rather than by a
sequence generator, because a gap in an append-only audit log is indistinguishable from a deletion.

### External anchoring

A checkpoint we sign proves the ledger is internally consistent. It proves nothing about whether we
rewrote it, because we hold the key. The property an auditor actually wants — this head existed at
this time and we could not have produced it later — requires a timestamp from a party with no
interest in the answer.

```json
{ "anchor": true }
```

The canonical checkpoint payload is hashed and the bare SHA-256 imprint is submitted to an RFC 3161
Time-Stamp Authority. The authority never sees a facility name, a borrower, or a number.

Before an anchor is recorded, and in this order because a later check means nothing if an earlier
one failed: the response status is `granted`; the token's message imprint equals the digest we
submitted; the nonce equals the one we generated, which is what makes replaying an old token
useless; the CMS signature over the signed attributes verifies against the embedded certificate and
its `messageDigest` covers the TSTInfo we read; and the signing certificate carries the
`timeStamping` extended key usage. Failing any of these refuses `ANCHOR_UNVERIFIABLE` and records
nothing — a checkpoint never claims an anchor it does not have.

Certificate-chain validation is reported separately and is three-valued on purpose.
`trust_anchor_verified` is `true`, `false`, or `null` when no trust anchor is configured here,
because "we did not check" and "we checked and it passed" must not collapse into the same field. The
raw token is stored verbatim so you can run `openssl ts -verify` against your own trust store and
reach your own conclusion without taking ours.

With no authority configured, `POST /ledger/checkpoint` still works and the checkpoint says
`anchor_type: NONE` with a notice stating exactly what it does and does not prove. Asking to anchor
refuses `ANCHOR_NOT_CONFIGURED`. There is no fallback anchor.

Requires Desk or above.

---

## Extraction

- `GET /extractors` — what is wired, and what it has been measured at on this account
- `POST /documents/{version_id}/extract/suggest` — candidates for one registered version
- `POST /facilities/{facility_id}/extractors/evaluate` — measure the extractor against your tags

Suggest takes the document text (it was never stored) and hash-checks it against the registered
version first: without that, the offsets returned would point into a document the system has never
seen. It returns candidates with `persisted: false`. Turning one into evidence goes through the
ordinary span path.

Both accept an optional `extractorId` and default to the single automated backend. Naming `manual`
refuses `EXTRACTION_NOT_AUTOMATED` rather than silently running the pattern extractor under a
backend id you did not ask for — and scoring the analyst's tags against the analyst's tags would
report 100% and mean nothing.

The patterns are the conventional constructions credit agreements are drafted from — `"X" means`,
`3.50:1.00`, `not to exceed 20%`, `within thirty (30) days`, `as of the last day of each fiscal
quarter`, step-downs, add-back language, currency amounts — matched exactly, with the surrounding
sentence returned as the candidate. The full regex catalog is published at `GET /policies`. The same
document produces the same candidates on every run, forever.

What it cannot do is tell whether the sentence it matched is the operative one. A definition quoted
in a recital, an example in a schedule, and the governing definition in Section 1.01 look identical
to a regular expression. That is not a defect to be tuned away; it is the boundary between pattern
matching and reading a contract.

**Confidence is measured or it is zero.** Evaluate scores candidates against your analyst-tagged
spans and stores precision *and* recall per label — reporting one without the other is how
extraction demos are made to look good, since a pattern that fires on every sentence has perfect
recall and one that fires once has excellent precision. A candidate is credited when it overlaps a
same-label tagged span by at least the intersection-over-union floor (0.5 by default), matching is
one-to-one so one tag cannot be credited to five overlapping candidates, and the tolerance is stored
with the figure because a precision number without it is not a number. Every measurement is
`in_sample: true`: these are documents your analyst had already tagged, so the result describes
agreement on documents already seen and does not forecast a new credit agreement.

Until a label has been measured, its candidates report `grounding_confidence: 0.0` and
`confidence_basis: "unmeasured"`. Zero is the honest figure, and it means a candidate would fail the
gate's confidence floor immediately rather than being quietly accepted.

---

## GET /policies

Publishes every precedence, gate, materiality, and derivation policy version as data, plus the
basket event types, the full breach transition map, the extraction pattern catalog, the API key
scopes including the ones that cannot be granted, and whether anchoring, WORM retention, and
row-level security are actually enforced on this deployment. A customer cannot audit rules they
cannot read.

Authenticated, because it reports the database role the application connects as and whether
row-level security is genuinely in force — operational detail for the account rather than something
to hand an anonymous caller.

### Row-level security

Tenant isolation is enforced in application code and, when `LEDGERCOVENANT_RLS` is enabled, by
Postgres row-level security on every tenant-scoped table. The policies compare `user_email` against
a session variable set from the authenticated caller on every connection, with `FORCE ROW LEVEL
SECURITY` as well as `ENABLE`, because without it the table owner — the role the app connects as — is
exempt and the whole exercise is decorative.

What that does and does not buy, stated precisely because "we have RLS" is often heard as more than
it is. It does stop a missing `WHERE` clause in application code from crossing tenants, which is the
realistic failure and the reason to do it, and it does stop a direct `psql` session as the
application role from reading anything, because that session has no principal set. It does **not**
stop a superuser, since Postgres exempts superusers unconditionally — `GET /policies` reports
`enforced: false` in that case even with every policy installed. And it does not stop code that can
run arbitrary SQL on the application connection, which could set the principal to anything. It is a
guard against mistakes, not against an attacker who already holds the connection.

---

## Billing

- `POST /checkout` — Stripe Checkout. `plan` for a subscription (`analyst`, `desk`), or `sku` for the
  one-time `audit_wedge` engagement
- `POST /checkout/confirm` — confirm a returning session
- `POST /webhook` — Stripe webhook (signature-verified, no JWT)

| Plan | Price | Facilities | Decisions/mo | Attestor seats |
| --- | --- | --- | --- | --- |
| Sandbox | Free | 1 | 25 | 0 |
| Analyst | $299/mo | 3 | 500 | 1 |
| Desk / Team | $1,499/mo | 15 | 5,000 | 5 |
| Enterprise | From $6,000/mo | Unlimited | Unlimited | Unlimited |

The Retrospective Evidence Audit is $25,000 one-time for 10 historical facilities and grants Desk
access. Larger scopes are quoted.

Sandbox has no evidence export and no replay, and signs with a development key. That is a design
decision, not an oversight: a free tier that emits a plausible-looking signed audit record would
damage the only thing this product sells.

---

## Reason codes

| Code | HTTP | Meaning |
| --- | --- | --- |
| `LEGAL_AMBIGUOUS` | 422 | Two or more provisions survived the precedence ladder |
| `LEGAL_MISSING_DEFINITION` | 422 | A term in the closure has no definition in force on the test date |
| `LEGAL_MISSING_DOCUMENT` | 422 | The governing provision has no document version |
| `LEGAL_CIRCULAR_DEFINITION` | 422 | Definitions reference each other through `USES` edges |
| `FACT_CONFLICT` | 422 | Sources of equal authority report different values |
| `FACT_MISSING` | 422 | A required fact has no assertion for the period |
| `THRESHOLD_MISSING` | 422 | The step-down schedule has a gap on the test date |
| `DERIVATION_CAP_UNDEFINED` | 422 | An add-back is capped but the cap was not tagged |
| `DERIVATION_NO_CONVERGENCE` | 422 | Circular caps did not settle within the declared budget |
| `BASKET_INSUFFICIENT` | 422 | The usage exceeds capacity on that date; pass `allowOverdraw` to record it anyway |
| `BASKET_NOT_FOUND` | 404 | No such basket or basket event |
| `BREACH_INVALID_TRANSITION` | 409 | That lifecycle step is not permitted from the current state |
| `EVIDENCE_LOW_CONFIDENCE` | 422 | Below the plan's confidence floor |
| `ATTESTATION_EXPIRED` | 422 | The attestation's validity window has passed |
| `ATTESTATION_UNAUTHORIZED` | 403 | The attestor lacked authority as-of the scope period |
| `ATTESTATION_INVALID` | 422 | Missing, revoked, or unverifiable — including a lifecycle step taken without one |
| `GATE_REFUSED` | 422 | The gate would not finalize; resolve the review items |
| `REPLAY_MISMATCH` | 409 | Same frozen inputs, same pinned policies, different result |
| `REPLAY_MISSING_ARTIFACT` | 409 | A frozen input no longer hashes to what was recorded |
| `REPLAY_TIER_FORBIDDEN` | 403 | That replay tier is not in your plan |
| `LEDGER_CHAIN_BROKEN` | 500 | Chain verification failed |
| `CANONICALIZATION_VERSION_DRIFT` | 409 | A hash was produced under a different canonicalization version |
| `FLOAT_IN_EVIDENCE` | 500 | A float reached a payload that must be exactly reproducible |
| `TIER_LIMIT` | 403 | Plan limit — facilities, seats, or a gated capability |
| `QUOTA_EXCEEDED` | 429 | Monthly decision limit |
| `ACCESS_DENIED_WALL` | 403 | Outside your deal-team scope |
| `AUTH_KEY_INVALID` | 401 | Not a key we issued |
| `AUTH_KEY_EXPIRED` | 401 | Expired, revoked, or the account behind it is not active |
| `AUTH_KEY_SCOPE` | 403 | The key lacks the scope, or the route requires a signed-in person |
| `AUTH_KEY_FACILITY` | 403 | The key is pinned to other facilities |
| `EXTRACTION_UNMEASURED` | 422 | The submitted versions carry no labeled analyst spans, so precision and recall are undefined |
| `EXTRACTION_NOT_AUTOMATED` | 422 | That backend is not automated, so there is nothing to suggest or measure |
| `ANCHOR_NOT_CONFIGURED` | 501 | No timestamp authority is wired; a checkpoint proves consistency only |
| `ANCHOR_REJECTED` | 502 | The authority refused or could not be reached; nothing was anchored |
| `ANCHOR_UNVERIFIABLE` | 502 | A token came back that does not bind to this checkpoint; not recorded |
| `WORM_NOT_CONFIGURED` | 501 | No WORM bucket is wired; retention cannot be enforced by storage |
| `WORM_LOCK_UNAVAILABLE` | 409 | The bucket has no Object Lock, so a write there would not be write-once |
| `WORM_WRITE_FAILED` | 502 | The store rejected the write; nothing was retained |
| `FACILITY_NOT_FOUND` | 404 | No such facility, or not visible to you |
| `INPUT_INVALID` | 422 | Malformed request — including span offsets that do not match the text |

---

## Super-admin

These live under `/api/admin/ai-products/ledgercovenant`, not the public product API. They require
`require_super_admin`. The store lifts RLS for these four reads only (`rls.principal_scope(admin=True)`).

| Method | Path | Notes |
| --- | --- | --- |
| GET | `/stats` | Subscriber counts, paid plans, facilities, decisions this month, open review, ledger length |
| GET | `/subscribers` | Optional `?email=` filter. Includes this month's decision count and facility count |
| PATCH | `/subscribers/<email>` | Body `{ "plan": "sandbox\|analyst\|desk\|enterprise", "status": "active\|canceled\|past_due" }` |
| GET | `/payments` | Optional `?email=` |
| GET | `/audits` | Optional `?email=`. Reason codes and hashes — no document text |

The UI is `admin.html` → LedgerCovenant tab.

---

## MCP

Tool descriptor: [`/mcp_tools/ledgercovenant_decide.json`](/mcp_tools/ledgercovenant_decide.json).
Same JWT, same quota, same gate as the dashboard. An agent gets `REVIEW` and review items in exactly
the cases a person would.
