Skip to content

Corpus API

The corpus surface is a set of additions to the platform's existing REST API, under /api/v1/corpus. It uses the same authentication as every other endpoint — a bearer token plus the active-group header — and every read and write is scoped to that group.

Authorization: Bearer <token>
X-Jaxon-Active-Group: <group>
Content-Type: application/json

Full request and response schemas are published in the Swagger UI and ReDoc references, generated from the OpenAPI spec. This page is the orientation: what the endpoints are for, how the pieces fit, and the rules a caller has to satisfy.

All of this has a screen

You never have to use the API to use the corpus. Every corpus object is created and edited in the product: claims on the Registry tab, including the ordered verdict claim with its level labels and glosses; corpus rules on the Rules tab; axioms on the Axioms tab; scope profiles, verification, dispositions and coverage obligations on Corpus Health; and the reasoning operations are the Analysis question catalog. A rule's local claim also becomes a registry claim by promotion.

This page is for integrations, bulk import, and automation — a nightly re-check, a gate in a pipeline, an agent that must validate before it asserts.

Operations

These five endpoints are the analysis surface. All of them resolve a slice first, and all of them return an attestation.

Method Path Modes Purpose
POST /api/v1/corpus/validate validate Are these claim values consistent with the corpus?
POST /api/v1/corpus/conflicts conflicts Find contradictions between rules; returns witnesses
POST /api/v1/corpus/whatif whatif_forward, whatif_backward, levers The what-if family behind the Analysis catalog
POST /api/v1/corpus/solve feasible, lowest, highest, mark Feasible regions, objectives over them, and ordered-verdict marking
POST /api/v1/corpus/slice/preview Resolve a slice and return its hash, counts, and staleness faults

Shared request fields

Every operation that reasons about a subject accepts the same core fields.

Field Type Meaning
scope string[] Context tags, e.g. ["jurisdiction:EU"]. Selects the active rules and the scoped axioms. Empty means everything available to the active group.
rulesets string[] Corpus ruleset refs, id or id@version. Default: every ruleset in scope.
document string The document whose stored corpus_claim_value rows ground the query. Note that this reads the corpus's own stored values, not a run's results — the Analysis tab sends contexts with the run's answers instead.
contexts object[] The batch form: [{ id, claims: [...] }], one entry per portion or subject
aggregate bool Append a synthetic __aggregate__ context whose claims are the union of every context's — the document-level pass that catches cross-portion mosaics. Each surviving value keeps a provenance whose span names the contributing context.
claims object[] Claim values supplied inline, overriding stored values for the same claim
inject object[] Hypothetical claim values. Any entry makes the whole result advisory.
inject_rule object[] Hypothetical rules, added for this request only and never persisted
goal string A boolean DSAIL expression over in-slice claims
timeout_ms int Per-request solver budget
require_verified bool Refuse an approximating fast path; answer from the solver only. Accepted and validated, but inert today — no approximating engine is wired into the served endpoints, so every answer is already solver-computed.

Exactly one of document or contexts is required by /whatif (all three of its modes) and /solve. Supplying both, or neither, is rejected. /validate accepts either, or neither — with no subject it validates the rules against the claim domains alone. /conflicts and /verify take no subject at all: they are data-free by construction and a request carrying claim values is rejected.

To ask a /whatif or /solve question about no particular subject — the rules over every possible input — send a single unconstrained context: "contexts": [{ "id": "any-subject", "claims": [] }]. Every claim is then free, which is the honest reading of "any subject", and it is exactly what the Analysis tab sends in its About these rulesets mode.

A claim value looks like this:

{
  "claim": "sys.freq_band_mentioned@1",
  "value": true,
  "origin": "extracted",
  "provenance": { "doc_ref": "spec-sheet", "span": "P3", "context_id": "P3" }
}

The reference is always version-pinned — id@version — because nothing resolves "latest" implicitly at solve time. "value": null marks the claim explicitly free, which is what abduction, feasible regions, and the safe mark solve over. origin is one of extracted, asserted, or hypothetical.

value is typed by the registry, and checked against it before anything is solved. A value the claim's declared type cannot hold answers 400 naming the claim and what the type requires — claim 'clas.portion_level@1' is a whole-number claim: it takes an integer, and ' ' is not one. A boolean claim takes true/false (or the 0/1 a JSON client may send for them) and nothing else: "false" is a non-empty string, and a string that looks boolean would bind to the opposite of what it says. A numeric claim takes a number, and an integral one takes a whole number — a fractional value is refused rather than truncated. An enum claim takes one of its allowed_values. A value naming a claim the slice does not contain, or a version it does not carry, is not refused: it is never bound, and the answer carries a warning saying so.

Mode-specific fields

/whatif

Field Applies to Notes
objective: "min_correction" whatif_backward Switches abduction for a cardinality-minimal correction set. Composes with goal; the pairing is the downgrade query. Every mutable claim must already be bound — a correction changes a value the subject has.
mutable with objective [{ claim, direction }] where direction is any, to_false, to_true, decrease, or increase. Required with min_correction, and meaningless without it.
corpus_scan: true whatif_forward Reserved for re-running conflict detection over the augmented slice. Accepted and validated, but inert today — it does not change the response; call /conflicts separately.

levers and whatif_backward both require a goal. Directions are type-checked: to_false and to_true are boolean-only, decrease and increase numeric-only.

/solve

Field Applies to Notes
solve_for feasible Claim ids whose feasible region is wanted. Required for this mode.
lowest / highest / mark those modes The target claim id. Must be an ordered claim with a range. Supply exactly one of the three.
unknown_policy mark pessimistic (the safe mark; default), optimistic, or neutral. Refused on lowest, highest and feasible — those answer about the constraint region, where there is nothing to resolve. See Objectives and the mark.

Response

Every operation returns the same envelope, with mode-specific keys added.

{
  "result_id": "…",
  "mode": "mark",
  "status": "consistent",              // consistent | inconsistent | unknown
  "engine": "smt",                      // smt | grail
  "verified": true,
  "advisory": false,                    // true if any input was hypothetical
  "entailed":   [{ "claim": "…", "value": true }],
  "unsat_core": [{ "kind": "rule", "id": "…", "version": 2, "summary": "…" }],
  "witness":    { "literals": [...], "rendered": "any portion where …" },
  "warnings":   ["unannotated_ordered_rules"],
  "timed_out":  false,
  "slice_hash": "…",
  "attestation": { "…": "…" },

  // mode-specific
  "optimum":         { "claim": "clas.portion_level", "value": 2, "display": "S" },
  "binding_core":    [{ "kind": "rule", "id": "mosaic.band_location", "summary": "…" }],
  "correction_set":  [{ "claim": "…", "from": true, "to": false }],
  "feasible_values": { "sys.operating_frequency": { "intervals": [[0, 10.0]], "units": "GHz", "may_contain_gaps": false } }
}

A batched request returns {"contexts": {"P1": {…}, "P2": {…}, "__aggregate__": {…}}} instead, one envelope per context.

Response keys by mode

extra='allow' on the envelope means each mode adds its own keys. These are the ones to read:

Mode Keys it adds
validate entailed (what the rules force, not merely permit), unsat_core and witness when inconsistent
conflicts conflicts: [{ kind, classification, core, witness }], where kind is unsatisfiable (the rules cannot be complied with at all) or conditional (an input class under which the determined verdict violates a rule). Plus undetermined_ordered_claims when an ordered claim in the slice has no floor/ceiling annotation, in which case status is unknown rather than consistent — the conditional pass could not run
whatif_forward diff: { gained, lost, changed } against the same subject without the hypotheticals. changed entries carry from, to, from_display, to_display
whatif_backward reachable (bool). When reachable and entailable, requires: [{ claim, value, display }] — the minimal assignment that entails the goal — and the same literals as a witness. When reachable but not entailable, reason explains that no assignment of the free claims forces it. When unreachable, unsat_core names what blocks it
whatif_backward + objective: min_correction correction_set: [{ claim, from, to }] and up to three alternates, each an equally-small alternative
levers levers: [{ claim, load_bearing, flips_to, from }] and goal_holds (true, false, or null when the goal is neither entailed nor refuted)
feasible feasible_values, keyed by claim id, in one of two shapes. A finite domain — boolean, enum, or an integral range small enough to enumerate — returns { values: [...], truncated }. A real-valued domain returns { intervals: [[low, high]], units, may_contain_gaps }; may_contain_gaps means the interval is a hull, so present it as approximate and never as exact. An empty intervals array means no value satisfies the goal
lowest / highest / mark optimum: { claim, value, display, low, high, resolved_at, resolution_note, unknown_reason, unknown_driven, assumed_claims } and binding_core — the constraints tight at the optimum, which is the answer to "why this level". low/high are the range the constraints admit, not a confidence. A collapsed interval says the corpus permits exactly one value; it does not say the evidence produced it, because a ceiling can exclude the completions that would have moved it. unknown_driven is the field that tells those apart — true whenever a claim with no answer influenced the value, so it can be true with low == high — and assumed_claims names the unanswered claims the constraints settled. Never infer confidence from the interval alone. resolved_at (low, high or pinned) says which end of that range value is, and resolution_note says why — an objective always returns its own end, a mark returns the one its policy chose

Two fields deserve attention from any caller acting on a result. advisory is true whenever an input carried origin: hypothetical; such a result must never be presented as a compliance or classification determination. verified is false when an approximating engine produced the answer without solver confirmation; set require_verified: true on the request to refuse that path entirely.

Asynchronous operations

Some requests answer 202 Accepted with a job instead of a result:

  • /conflicts with no rulesets named — a corpus-wide scan
  • /verify — always
  • any request whose contexts array has more than 20 subjects
{ "job_id": "…", "status": "accepted", "status_url": "…", "result_url": "…",
  "operation": "verify", "slice_hash": "…" }
Method Path Purpose
GET /api/v1/corpus/jobs/{job_id} State, percent complete, and partial results as they are produced
GET /api/v1/corpus/jobs/{job_id}/result The finished result

Job state is one of pending, running, completed, failed, or cancelled. Partial results stream — for a verification run, the early structural findings reach the poller long before the pairwise stage finishes.

Verification

The static verification suite. These take no claim values — verification is a function of the slice alone, and a request carrying document data is rejected.

Method Path Purpose
POST /api/v1/corpus/verify Run the full suite over a scope profile, or over every declared profile. Always a job.
POST /api/v1/corpus/preflight Check an unsaved draft rule or axiom; returns only what the draft introduces. Synchronous.
GET /api/v1/corpus/verification/latest The latest report per profile, plus the profiles with no report for their current slice
GET /api/v1/corpus/verification/{report_id} One stored report
GET /api/v1/corpus/verification List reports, newest first
POST /api/v1/corpus/findings/{fingerprint}/disposition Record a disposition against a finding
GET /api/v1/corpus/dispositions List recorded dispositions

/verify accepts refresh: true to recompute rather than returning the cached report for an unchanged slice hash. A report carries status (clean, warnings, errors, or halted), halted_at when the run stopped early, the findings array, counts by severity and by class, a truncated flag when the pairwise stage exhausted its comparison budget, and a stale flag set when the profile's current slice hash no longer matches.

status is immutable — it is what the checks produced. open_status is the field a badge should read: the same four values, counting only the findings nobody has recorded a decision against, with decided_findings saying how many were excluded. Computed on every read, like stale, so it moves when a decision is recorded or withdrawn while the report itself does not. It never softens halted: a run that stopped at V0 or V1 never reached most of the corpus, so deciding what it found says nothing about what it never ran.

The suite halts in two places, and halted_at says which:

  • V0 — an unsupported_construct. The slice cannot be compiled, so nothing downstream would mean anything.
  • V1axiom_set_inconsistent. A contradictory crosswalk makes every downstream rule conflict an artefact of it.

A halted report is not a clean one and must not be read as one. See what each finding means.

/preflight takes the draft inline — { kind, id, dsail, summary, role, target_claim_id, shape, version } — plus a depth (0–3, default 1) controlling how far around the draft the neighbourhood extends. Nothing is persisted: not the draft, and not the vocabulary an unregistered @namespace.name in it would mint.

Minting is modelled rather than refused, because a save mints. source_validation.ok stays true, source_validation.minted lists the claim ids a save would create, and two warning faults report them: minted_vocabulary, whose detail carries claim_ids and persisted: false, and — where the name sits within the near-match cutoff of one the registry already holds — probable_misspelling, whose detail carries claim_id and near_matches (claim ids, best first). The same two codes come back from a save, with persisted: true and the past tense, so one reader handles both. The draft is verified against the slice including that modelled vocabulary, which is the slice the save would produce. A source that genuinely does not load — a syntax fault, a type error, an undeclared bare name — answers source_validation.ok: false with findings: [], because there is nothing to solve over.

A disposition body names the disposition (precedence, accepted_redundancy, not_a_conflict, or deferred), a required rationale, an optional scope, and — for precedence — both the governing and subordinate rule at their versions, which is enforced. The UI's fourth action, Add distinguishing claim, is not a fourth disposition kind: it posts a merge-candidate and records the finding as deferred.

A precedence disposition changes the slice hash

Recorded precedence is compiled into subsequent queries: the subordinate rule is guarded so it does not apply where the governing rule's condition holds. Because that changes what the rules mean, the active precedence set is part of the slice hash, so recording or removing one invalidates cached reports and retires any model distilled under the previous configuration. See Corpus Health.

Whether a decision still applies

A disposition is matched to a finding by a fingerprint that pins the implicated versions, so editing any implicated rule retires it and the finding is reported again — including on an edit that changed only a summary, and including a re-pin. That is the intended safety property, but it means a stored decision cannot be read as an approved exception without checking. Every disposition record therefore carries where it stands, computed on each read against the corpus as it is now:

Field What it carries
applies The field to read. true for standing in_force and for nothing else
standing in_force, invalidated, not_arising or unmatched
standing_label / standing_note The same words the Decisions tab shows, so a report and the screen cannot disagree
superseded_by_fingerprint For an invalidated decision, the finding now reported in its place
changed_since Which pinned objects moved, e.g. rule:alpha.waveform_floor 3 → 4

The other half of the join is on the finding: disposition_id and disposition name the decision acknowledging exactly that finding, and previously_accepted with prior_disposition_id mark a finding decided under earlier versions and reported again because they changed. A finding re-surfaced this way carries a fingerprint no disposition names, so without those two fields nothing on the record says a decision was ever made.

GET /dispositions?applies=true is the live-exceptions list in one call; applies=false is everything that has lapsed.

A release gate must read applies, not the row's existence

Listing /dispositions and counting rows reports expired exceptions as approved ones. The four standings are exhaustive and only the first is a live acknowledgment.

Objectives and the mark are different questions

solve answers four, and the split between the last two is the point.

Mode Question Takes unknown_policy?
feasible Which values may each named claim still take? No
lowest What is the lowest value the rules admit for this ordered claim? No
highest What is the highest? No
mark What should this subject be marked at? Yes

An objective is a fact about the constraint region: lowest is optimum.low, highest is optimum.high, and there is nothing for a completion policy to resolve — the same reason feasible takes none. Supplying unknown_policy on one is refused, not ignored: silently accepting input you do not act on is how a caller ends up reading a number that answers a different question.

A mark is a verdict about a subject, and the claims nobody answered have to be resolved before there is one:

unknown_policy The mark
pessimistic (default) The worst case — an unanswered claim can raise a mark, never lower it. The safe mark, and what the run path and the Analysis chip ask for
optimistic The best case
neutral No value, only the range, while a free claim can still move it

Every answer carries the admissible range as optimum.low / optimum.high, which end it returned in optimum.resolved_at (low, high or pinned), and a sentence explaining it in optimum.resolution_note. Where the constraints leave nothing free the range is a point and all four modes agree.

minimize and maximize are retired, and answer 422

They were one operation with the mark until v2.7.0, with unknown_policy choosing which end of the range came back — four combinations of direction and policy producing only two answers, and a bare minimize returning the high end.

Under the split the identical call would return the low end: two levels apart on a classification scale, with no error and no warning. So both names are refused rather than reinterpreted, and the refusal names both ways forward — mark for a subject's mark, lowest / highest for the ends of the admissible range. Every caller re-reads, not only the ones who happened to pass unknown_policy.

Integrating against observed behaviour is what people do when a default is undocumented, which is why "they were relying on an undocumented default" is not a migration plan. common/test/test_corpus_ops.py::ObjectiveIsNotAMarkTest pins the split; the REST tests pin that no retired mode ever returns a value.

Claim references: three formats, one body

A solve or what-if body asks for claim references three ways, and mixing them is refused at the boundary rather than misread:

Field Format Example
claims[].claim version-pinned clas.portion_level@1
goal DSAIL, with the sigil @clas.portion_level >= 2
minimize / maximize / solve_for bare claim id clas.portion_level

A pinned or sigil-prefixed reference in the last group answers 400 naming the format fault and the bare id to write. A version pin is not silently honoured there: the version comes from the slice, and answering clas.portion_level@1 against a slice pinning @2 would answer a question nobody asked.

Declarative inputs

Method Path Purpose
GET/POST /api/v1/corpus/coverage-obligations The requirements V5 checks the corpus against
PATCH /api/v1/corpus/coverage-obligations/{obligation_id} Edit one — appends the next version
DELETE /api/v1/corpus/coverage-obligations/{obligation_id} Withdraw one, with its findings and their dispositions
GET/POST /api/v1/corpus/scope-profiles The scope/ruleset combinations verification runs over
DELETE /api/v1/corpus/scope-profiles/{profile_id} Delete one, with its reports and the decisions only they held
GET /api/v1/corpus/rulesets The corpus rulesets that resolve, with rule counts

A coverage obligation is a predicate (a boolean DSAIL expression over registry claims), a target_claim_id, a requirement of must_be_determinate or must_not_equal_default, a default_value for the latter, and a human summary. A scope profile is a name, a scope tag list, the rulesets in play, and is_publish_gate.

POST and PATCH answer 400 when the predicate cannot compile against the registry: a name the registry does not have, a bare identifier, a target_claim_id that is not a registry claim, or a type errorAnd(@a.bool_claim, @b.bool_claim == 1) is refused here in the same sentence the axiom and rule editors use for it, because the three surfaces compile through one checker. An obligation that cannot compile can never be checked, and every run would report it as coverage_obligation_invalid. A predicate that does not parse answers 422 with a sentence about the submitted text — its own character positions, no parser token set.

The compile here is registry-wide, not slice-scoped, and that is what makes the type check reachable: identifier resolution happens first inside one compile, so a slice-scoped check fails on the first name the profile does not reach and never reaches the types. Whether a particular profile's slice loads those claims is a separate question, answered per report — V5's coverage_obligation_invalid distinguishes a name the registry does not hold from one it holds that the slice does not load, and gives the fix that matches.

Which profiles an obligation reaches is decided from the profile's own bounds, not from the obligation alone. A profile naming scope tags gets every obligation whose tags it names, plus the untagged ones. A profile naming only rulesets names no context to load a tagged obligation under, so only untagged obligations reach it. A profile naming neither is the whole group and gets all of them. The obligation clock that marks reports stale is narrowed identically — read wider than the set a report was verified against, one declaration anywhere in the group stales every profile's report. See Which obligations a profile answers for.

PATCH appends the next version rather than rewriting the current one, because stored reports cite the obligations they checked as id@version. DELETE withdraws every version, removes the coverage findings about the obligation from the reports carrying them (recomputing their status and counts downward), removes the dispositions recorded against those findings, and answers with the counts. Both mark the affected profiles' reports stale — including a DELETE, which would otherwise wind the obligation clock backwards and leave those reports reading as current. See Corpus Health.

POST /scope-profiles answers 400 when a ref in rulesets names no corpus ruleset, with the ref in the message. This is the counterpart of the obligation's vocabulary check and it guards a quieter failure: a bad claim name in an obligation becomes a visible coverage_obligation_invalid, whereas a bad ruleset ref resolves an empty slice, and an empty slice has no contradictions in it — so the profile reports clean. GET /rulesets is the catalog every picker offers, derived from rule membership alone: a ruleset exists because a rule belongs to it, so a ref a profile merely names never appears there.

Verification reports an empty slice as an empty_slice finding rather than a clean run — an error on a publish-gate profile, a warning elsewhere — and the publish gate carries an empty_slice blocker computed from the resolved slice rather than from a stored report, so a gate over no rules can never be satisfied.

DELETE /scope-profiles/{profile_id} removes the profile, the verification reports that ran under it, and the dispositions whose findings survive in no other profile's report; it answers with reports_deleted, findings_discarded, dispositions_withdrawn and was_publish_gate. A report names its profile as its subject, so once the combination is gone nothing can re-run it or say whether what it found still holds.

Those counts are complete even against a verification that is already running. A report may only be written while the profile it names exists, so a job in flight when the profile is deleted writes nothing for it and reports the profile under withdrawn_profiles in its result instead of failing. Without that, a delete could count the reports it found, report honestly, and then have one more land behind it — a verdict about a scope combination nothing can re-run, absent from the profile list and from every count the receipt gave.

Registry and catalog

Method Path Purpose
GET/POST /api/v1/corpus/claims Browse and register canonical claims
GET /api/v1/corpus/claims/{claim_id}/versions Version history for one claim
PATCH /api/v1/corpus/claims/{claim_id} Append the next version, or move vetting
DELETE /api/v1/corpus/claims/{claim_id} Delete every version — refused while a rule links it
GET/POST/PATCH /api/v1/corpus/axioms The axiom catalog
DELETE /api/v1/corpus/axioms/{axiom_id} Delete every version of an axiom
GET/POST/PATCH /api/v1/corpus/rules The corpus rule catalog
DELETE /api/v1/corpus/rules/{rule_id} Delete every version, with memberships and links
DELETE /api/v1/corpus/dispositions/{disposition_id} Withdraw a recorded decision
GET/POST /api/v1/corpus/merge-candidates The vocabulary review queue
POST /api/v1/corpus/merge-candidates/{id}/decide Confirm, reject, or merge a candidate
DELETE /api/v1/corpus/merge-candidates/{id} Remove one from the queue, decided or not
GET /api/v1/corpus/results/{result_id} The stored attestation envelope for a prior result

Registry entries are immutable. A PATCH that changes a field appends a new version; a PATCH carrying vetting alone transitions the current version in place. A transition toward authoritative runs the publish gate and answers 409 with a PublishGateResponse listing every blocker (no_report, stale_obligations, open_error, undisposed_warning) unless the body carries both an override_reason and an approver, which are then recorded against each blocking finding. Both are required text: a whitespace-only override_reason is refused with 422 and the transition stays blocked, because it is the reason recorded against every blocker and a gate released against a blank is a gate nobody signed off.

A PATCH that changes nothing appends nothing. A version per edit is the design; a version per save button press is not — an immutable history where most rows record no change is one nobody can read. So a request whose every supplied field already holds the value being sent answers 200 with the current version, having written no row and no audit entry, and a vetting already in the requested state is likewise not re-transitioned. 304 was the alternative and is wrong here: it belongs to conditional requests, and the caller is not revalidating a cached copy — it asked the registry to say something, and the registry already says it, so the right answer is the state, with a body. Clients tell the two cases apart by the version in the response. This is checked field by field on the whole request only: a request carrying one real change is applied exactly as it always was, source re-validation and claim re-pinning included, so partial saves are unaffected.

On a PATCH, an omitted field is carried forward and a field sent as null is cleared. These are two different requests, and the difference is what lets a claim go back to being solved-for: {"question": null} takes the extraction question away, so the claim reads solved_for where a rule or axiom determines it and incomplete where none does. On a rule, {"role": "constraint", "target_claim_id": null} is how a floor becomes a plain constraint. The fields with no absent state — a claim's claim_type, gloss and three flags, a rule's or axiom's dsail, summary, role/shape and scope, and vetting everywhere — answer 422 naming the field rather than ignoring the null. A rule edit whose result would be a floor with no target, or a constraint that kept one, answers 400.

§3.1's coherence rules are enforced in the database, and refused as validation. A claim carrying display labels must be numeric, ordered, integral and closed by a range; per-level display_glosses need the display they gloss; a floor or ceiling must name its target_claim_id and a constraint must not. These are CHECK constraints rather than Python checks, so no write path can get around them — but they are reachable by filling in a form, so tripping one answers 400 with a sentence saying what the registry requires, not a 500. The SQL is never surfaced.

A claim enters the registry three ways: POST /claims, a merge candidate decided confirmed, and one decided merged. Every decision requires a non-empty rationale, exactly as a disposition does — a decision with no recorded reason is refused at the boundary, so a script gets the same treatment as the workbench. The text is trimmed before it is checked and it is the trimmed text that is stored, so spaces are not a reason. Deciding a candidate records the review decision and acts on it:

  • confirmed registers a draft claim under the id the proposal names (or under registered_claim_id, for a solver-pinned slot that names none). Over an id the registry already holds it answers 409 — that case is a merge, not a second claim under one name.
  • merged names the claim the proposal was folded into in merged_into_claim_id, and registers the proposal's own claim too: both names have to resolve for a bridge between them to assert anything, and for a rule already written against the proposal's name to compile. Send the bridging axiom with the decision — axiom_dsail, axiom_summary and minted_axiom_id, plus an optional axiom_shape — and the claim and the axiom are written in one transaction: all of it, or none of it. The axiom names the claim being registered, so it cannot be posted to /axioms beforehand. A source that fails load-time validation answers 400 with the whole validation and leaves nothing behind.
  • rejected writes nothing but the decision.

Every decision answers with what it wrote: registered_claim_id, registered_claim_version, registered_claim_type, registered_claim_gloss, registered_claim_question (null means no question was written, so the claim is incomplete until a rule or axiom determines it), registered_claim_created (false when the claim was already registered and the decision only bridged it), and minted_axiom_id.

Every merge-candidate representation — the one POST /merge-candidates answers with, the rows GET /merge-candidates returns, and the one a decision answers with — also carries near_matches: the registry claims whose ids sit within the near-match cutoff of the proposal's own claim id, best first, each as { claim_id, claim_type, gloss, allowed_values, score }. claim_type and allowed_values are there so a caller can tell which matches an axiom could actually relate without a second lookup, and score is the difflib ratio the cutoff was applied to.

It is computed on read, from the same difflib path that produces near_matches on a mint's probable_misspelling fault, so a receipt and a queue row can never name different claims. On read rather than stored at mint time because the answer is about the registry as it stands: a candidate sits in the queue precisely while vocabulary is being added around it, and the claim it should be merged into may be registered after it was queued. The proposal's own claim id is excluded — a mint registers it, so a read-time id match would otherwise rank it first at a perfect score. A solver-pinned slot, which names no claim id, gets [].

DELETE /merge-candidates/{id} takes a proposal off the queue, pending or already decided. Removing a decided one undoes nothing it decided: the claim a confirmation registered and the axiom a merge minted are registry objects rules pin, so they stay, and the receipt names them under registry_objects_kept — withdraw those through DELETE /claims/{id} and DELETE /axioms/{id}. The decision itself lives in the append-only audit log, which is where it was recorded and where it remains; the receipt also carries status_at_deletion, was_decided, the reviewer and rationale as they stood, and audit_rows_retained, the number of surviving audit rows about the candidate including the deletion's own.

registry_objects_kept is read from the registry at deletion time rather than copied off the queue row, so it lists only objects that are actually there. Nothing forces the queue row and the registry to agree: an axiom a merge minted can be withdrawn on its own, months before anyone clears the decided candidate. Listing it anyway would name a withdrawal target that answers 404, which is the one thing this field exists to prevent. What the decision recorded is not lost either way — the deletion's audit row carries merged_into_claim_id and minted_axiom_id whether or not those objects survive.

A claim registration looks like this:

{
  "claim_id": "clas.portion_level",
  "claim_type": "numeric",
  "gloss": "The classification level at which this portion must be marked.",
  "question": null,
  "ordered": true,
  "integral": true,
  "range_min": 0,
  "range_max": 3,
  "display": { "0": "U", "1": "C", "2": "S", "3": "TS" },
  "display_glosses": { "0": "Nothing in the portion requires protection." },
  "vetting": "authoritative"
}

claim_type is one of boolean, numeric, or enum; the spellings integer, real, and symbol are accepted as aliases, with integer additionally implying integral: true. A display map is a labelling of whole levels, so supplying one makes the claim ordered and integral when the slice is built; supply a range as well, so the levels are bounded. display_glosses is keyed the same way and carries one sentence per level — it is what the beside a mark chip or a level dropdown serves, so a level the map omits shows no affordance rather than an empty one.

ordered on an enum claim declares that allowed_values is in order, and that is what makes the claim comparable: a rule or axiom may then write <, <=, > or >= between it and a quoted label of its own domain, in either position, and the corpus compiles the comparison as the disjunction of the equalities that declared order satisfies — @risk.tier >= "warn" over ["info", "warn", "error"] becomes Or(@risk.tier == "warn", @risk.tier == "error"). There is no ordinal representation to address; the declared order is the whole of the meaning, which is why re-ordering allowed_values appends a version over a different domain rather than the same domain re-spelled.

Without ordered an enum takes == and != only, and an ordering operator on it is a type error naming the domain that declares no ordering. Two enum claims compared against each other are refused on both forms. Two further limits are worth knowing before an enum is registered ordered: a display map is a labelling of a numeric scale, so the registry refuses one on an enum — an enum's allowed values are already its own labels — and an ordered enum is not a minimize/maximize target or a floor/ceiling target, both of which require an ordered claim with a range.

{
  "claim_id": "risk.tier",
  "claim_type": "enum",
  "gloss": "The risk tier the portion establishes.",
  "question": "What risk tier does the portion establish?",
  "allowed_values": ["info", "warn", "error"],
  "ordered": true,
  "vetting": "draft"
}

Every claim response carries a derived, read-only extraction_status alongside question:

extraction_status Means
extracted The claim has a question, so extraction answers it.
solved_for No question, and a rule or axiom determines it — the deliberate declaration an ordered verdict level makes.
incomplete No question, and nothing determines it. The claim can take no value by any route, so every verdict reading it is UNKNOWN. Verification reports the same state, scope-limited, as undetermined_solved_for_claim.

A boolean claim may also carry silence_reading"text" or "world", or null — which settles what an unanswered document means for it: "text" answers False on silence because the document is the whole subject, "world" answers Unknown because the document is only evidence. Null leaves it undeclared, and the judge reads the intent from the question's wording on every call. It is an ordinary versioned field: changing it appends a version, because it changes what the claim asks. Meaningless on a numeric claim (an unstated number is Unknown either way) and on a solved-for one (never asked).

Beside it, every claim response carries a derived, read-only reading_status:

reading_status Means
declared The author settled it, so every run reads the claim the same way.
wording Undeclared, and the wording lands squarely enough on one side that the judge reads it the same way.
ambiguous Undeclared, and the wording asks something of the text and something about the world. The judge decides per call, so the same document can answer False on one run and Unknown on the next — which under the safe mark moves the verdict without moving the document. Declaring silence_reading clears it without rewording.
not_asked No question, or not a boolean claim, so nothing turns on its silence.

Like extraction_status it is computed on read and never stored, and sending it on a create or patch is refused. Because it comes back on the list endpoint, GET /corpus/claims answers "which of my claims can give two different answers to the same document?" in one call, without running anything: the ambiguous ones can, and the other three cannot.

Every claim response also carries three read-only fields saying where the version sits relative to what the rules governing the claim pin — pin_status, pinned_version, and pin_drift:

pin_status Means
not_pinned No current corpus rule links the claim, so no verification report speaks to it. pinned_version is null.
pinned This is the version its rules pin, or an older one. What verification proved and what a run asks are the same thing.
ahead_documentation Newer than the pin, but the question and the declared reading are the pinned ones, so a run asks exactly what was checked. Nothing to do.
ahead Newer, and the question or the declared reading moved. A run asks this version while every report describes the pinned one — and nothing is stale, because the pin and the slice hash are both unchanged. pin_drift names which of the two moved.

The asymmetry behind it: verification pins claim versions and evaluation floats to the current one. A corpus rule's claim links are frozen at the rule version that wrote them; a ruleset rule naming @ns.foo unversioned has its runs inherit the current question and reading. pin_drift therefore only ever holds "question" or "silence_reading" — the two fields a run reads live — and is empty unless pin_status is ahead.

Read over current rule versions only, unlike linking_rules below: a superseded rule version governs nothing, so a pin it still carries is history rather than a live claim on the vocabulary. Verification reports the same fact per slice as stale_pin, graded the same way — a warning when a live-read field moved, an info when only documentation did.

Every claim response also carries a read-only linking_rules: the ids of the rules that pin the claim, across current and superseded rule versions. It is the set DELETE /corpus/claims/{claim_id} refuses on, served so a surface offering that delete can say whether it will be refused before the call. Rules only — an axiom naming a claim does not block the claim's deletion, so listing one here would predict a refusal that does not happen.

extraction_status is computed on every read from the group's current rules and axioms and never stored, so it cannot go stale: a floor or ceiling determines only the claim it targets, an axiom determines every claim it names, and an unannotated constraint gets the benefit of the doubt. Sending extraction_status on a create or patch is refused — it is not an input.

A rule registration carries rule_id, dsail, a required summary (the sentence the explanation surfaces render), completion_policy, role (constraint, floor, or ceiling), target_claim_id for a floor or ceiling, authority_source, owner, scope, and rulesets — the corpus ruleset membership, where a bare name means version 1, so "policy.internal" is stored as policy.internal@1. An untagged scope means the rule is in scope everywhere. An axiom carries axiom_id, dsail, summary, shape (equivalence, subsumption, disjoint, or refines), scope, and owner.

Every create and update runs load-time validation: the DSAIL must parse, every @namespace.name must resolve to a registry claim at the pinned version, every bare identifier must be declared local, and the formula must type-check against the claim types. A pin below the registry's current version is a fault surfaced as needing review — never an automatic upgrade.

PATCH /corpus/rules/{rule_id} with {"repin": true} accepts the upgrade. Pins do not move on their own, and a rule's links are rewritten only when its source is re-validated — which happens only when a field actually changes. Without a way to say "re-resolve this rule, nothing else", a rule nobody edits while its claims move keeps its old pins for good, and goes on contributing a stale_pin to every verification report and a warning to every mark it takes part in. A re-pin resolves the rule's stored source against the registry as it stands now, so it re-validates like any save and answers 400 if the source no longer resolves; it appends a version, recording who accepted the newer wording; and it carries the rule's ruleset membership and every other field forward untouched. It may be sent alone or beside real field changes. A rule already pinned to current appends nothing and answers 200 with the current version, the same as any other no-op PATCH, so a caller clearing drift across a corpus can send it to every rule. repin is an action rather than a stored setting: false and null are refused with 422 rather than read as "do it".

DELETE on a claim, rule or axiom removes every version — there is no coherent way to remove only the current one and leave a history behind — and answers with versions_deleted, the rulesets_left, and reports_staled: the number of stored reports this deletion invalidated, measured either side of the delete rather than counted from the ones already stale. Stored reports are never amended, only staled; they describe a slice that no longer exists, which is the true thing to say about them. A claim any rule still links answers 409 rather than breaking the rule's vocabulary. DELETE /dispositions/{disposition_id} withdraws a recorded decision, so the finding it acknowledged surfaces again and the publish gate stops releasing it.

Every corpus delete records the row it removed. The delete's audit_logs entry carries the removed row's identifying content in changes, not just the id and the actor: a claim's type, gloss, question and domain; a rule's or axiom's DSAIL source, role, scope and ruleset memberships; an obligation's predicate, target and requirement; a profile's scope tags and rulesets; a merge candidate's payload and decision; and a disposition's finding fingerprint, the rules it ordered, its rationale, its override and who decided it and when. A delete row that records only that something was deleted cannot answer "what happened to this decision", which is the one question an append-only log about governance objects exists to answer. Read them through the audit log with entity_type of corpus_claim, corpus_rule, corpus_axiom, corpus_coverage_obligation, corpus_scope_profile, corpus_merge_candidate or corpus_finding_disposition.

How a refused request reads

A corpus request refused at the boundary answers 422 with detail as a string — one sentence per fault, each terminated — and the framework's own error list under errors, field paths and all, so nothing a developer needs is lost. (Outside the corpus paths the platform's usual FastAPI 422 shape is untouched.)

Two rules govern the sentences. A field whose emptiness has a product meaning gets the product's sentence: a blank summary and a blank axiom_summary are the same field twice named and answer identically, as do dsail and axiom_dsail. Every other fault keeps the boundary's own words, because they are about the content that was actually sent — a DSAIL diagnostic naming the character an unclosed paren is at must not be replaced by "a predicate is required", which is the opposite of the truth.

A sentence that does not name its own subject is prefixed with the field, and the field is the path: a fault inside a collection reads 'contexts[1].id' was refused: …, so a batch of twenty subjects says which one to fix. Identical sentences are said once; the path is what keeps that safe, because two malformed contexts are two different sentences rather than one repeated.

The agent contract

Whenever an agent is about to make a claim in an area the corpus covers, it should call /validate first. On an inconsistent result it must surface the unsat_core rather than proceeding — the core names the rules and axioms that make the claim untenable, which is the part a human needs.

  • The Corpus — the concepts behind claims, axioms, scopes, and slices
  • Analysis — the ten questions these endpoints answer, and how each answer reads
  • Vocabulary Workbench — authoring claims, axioms and rules against this surface
  • Corpus Health — what /verify checks, and what it deliberately does not
  • API Reference — the generated OpenAPI browsers