Phase 3: Contracts¶
Status: approved 2026-11-10, with two security follow-ups (below) · Inputs: 01-requirements.md, 02-architecture.md
| File | What it fixes |
|---|---|
| openapi.yaml | Every endpoint: proxy, ingest, outcomes, policies, review, admin, ops (OpenAPI 3.1) |
| schemas/ | JSON Schemas (draft 2020-12): question/1, record/1, outcome/1, fit/1, policy/1, drift_report/1, bundle/1, plus shared definitions |
| examples/ | One valid document per schema, all describing the same refund-fraud decision |
| wire-formats.md | How /v1/systemone and OpenAI /v1/decisions traffic becomes records; the facts about each runtime |
| hashing.md | Canonical JSON, question_id, state hash, policy fingerprint and bundle ETag, with test vectors |
| sdk.md | The Python SDK's interface and exact gate algorithm; how the TypeScript SDK differs |
| vectors/ | generate.py, the reference computation, and vectors.json |
The numbers in the examples are illustrative, internally consistent and computed, never results. Only demo data may fill the homepage.
Conventions¶
Versions.
- The URL carries the API version (/v1). Every payload carries its schema version
("schema": "record/1").
- Adding an optional field keeps the version; anything else bumps it.
- The server accepts SDKs one minor version older (NFR-15).
Keys. Authorization: Bearer <key>. In proxy header mode the key goes in
X-Trimset-Key, so Authorization stays free for the upstream key.
| Prefix | Scope | Can call |
|---|---|---|
tsp_ |
proxy | Proxy endpoints only |
tsi_ |
ingest | Proxy, /v1/decisions:log, /v1/decisions:actions, /v1/outcomes, GET /v1/policies |
tsr_ |
review | /v1/review/* |
tsa_ |
admin | Everything, including /v1/admin/* |
A key is its prefix plus 32 base62 characters. The prefixes make leaked keys easy to find with secret scanners and easy to redact in logs.
Errors. RFC 9457 application/problem+json, with type set to
https://trimset.dev/problems/<slug>. The exception is the proxy, which answers in the
upstream's format (wire-formats.md §6).
| Slug | Status | When |
|---|---|---|
invalid-request |
400 / 422 | The body or a parameter fails its schema |
unauthorized |
401 | Missing, unknown or revoked key |
forbidden |
403 | The key's scope doesn't allow this |
not-found |
404 | No such resource in this project |
illegal-transition |
409 | A policy can't move from its state to the requested one |
name-taken |
409 | The question name is already used |
unknown-question |
422 | A record names a question_id the server hasn't seen, and the batch has no definition |
question-hash-mismatch |
422 | A definition doesn't hash to its question_id |
timestamp-out-of-range |
422 | ts is outside [now − retention, now + 5 minutes] |
too-large |
413 | Over 1,000 items or 1 MB (8 MiB on the proxy) |
unavailable |
503 | The database can't answer and there is no earlier answer to fall back on (for example, a key never seen before) |
Batches.
- At most 1,000 items.
- The reply is 200 when everything was stored, matched or held. It is 207 when some
items were rejected; the valid items are kept either way, and errors[] lists the
rejected ones by index.
Idempotency.
- Records are deduplicated by (decision_id, ts).
- Outcomes are deduplicated by outcome_id when it is sent. Without it, a repeat adds a
history row but leaves the current label as it was.
- For action reports, the last one wins.
Ids and time.
- decision_id should be a UUIDv7. The server reads its timestamp to find the partition;
other UUID versions work, but more slowly.
- Times are RFC 3339 in UTC.
Paging. cursor and limit (at most 1,000). The response has next_cursor, which is
null on the last page.
Long operations. Fits, compiles and deletions answer 202 with a Job and a Location
header. Poll /v1/admin/jobs/{job_id}.
Changes to earlier documents¶
The official vendor docs, read for this phase, changed these:
| Was | Now | Why |
|---|---|---|
| Ollama "0.40 and later" (brief, FR-04) | Ollama 0.35.0 and later for local models; 0.40.5 for cloud | Ollama's own docs |
| Format chosen by path (FR-05) | Chosen by the upstream's format, which the operator sets | Ollaya serves the systemone format at /v1/decisions too |
| Silent upgrades detected for every upstream (FR-39) | Only where the response names the version: fully on TypeSafe, partly on Ollaya, not on Ollama or OpenAI | They echo the requested name |
| Probabilities must sum to 1 within 1e-6 (FR-02) | Within 1e-3, then renormalised | Vendors round to 4 decimals; Ollama's own example sums to 0.9999 |
| Proxy body limit 1 MB (ADR 0011) | 8 MiB, configurable | Image requests; matches Ollaya |
| Three key scopes (FR-52) | Four: adds review |
Reviewers shouldn't hold keys that can send outcomes |
| Action taken only in log records (FR-33) | Also POST /v1/decisions:actions |
In proxy mode, the record is written before the app's gate() runs |
| No refusals | refusal: true with probabilities: null; gate returns the default with reason refusal |
OpenAI answers can be refusals |
| Policy transitions as one URL each | POST /v1/admin/policies/{id}/transitions with {action} |
Avoids clashing with /{id}/diff |
After two security reviews of this phase: - Upstreams and alert channels are defined only by the operator, in the server environment, with each key bound to its URL. Projects pick them by name, so the API never carries a URL, a header or the name of a secret (ADR 0011). - Link templates must be http(s).
These close threats E2, E3 and the new E4 in the architecture doc.
ADR 0006 also gained a rule: with a false-pass target, the block threshold is the highest one that keeps escalation within the cap.
How to check this phase¶
cd docs/03-api
uvx --from openapi-spec-validator==0.7.2 openapi-spec-validator openapi.yaml
for s in question record outcome fit policy drift_report bundle; do
uvx --from check-jsonschema==0.33.0 check-jsonschema --schemafile schemas/$s.schema.json examples/$s.json
done
uv run --no-project --with rfc8785==0.1.4 python vectors/generate.py --check
All three pass as committed. I also confirmed that each check can fail: a broken schema reference fails the OpenAPI check, and three deliberately invalid documents (a refusal that has probabilities, an outcome naming both a decision and an external_ref, a risk target with no α) fail their schemas.