Overview
| Route | Scope | Purpose |
|---|---|---|
POST /v1/decisions | decisions:write | Make a decision, or replay the stored one for a reused idempotency key |
GET /v1/decisions/{id} | decisions:read | Read a record back, scoped to your tenant |
- Base URL. Given with your invitation. Routes are written relative to it. By design the service answers only direct calls from allowed private networks; see access.
- Format. Request and response bodies are JSON (
Content-Type: application/json). Responses carryCache-Control: no-store. - Versions. The route prefix is
v1. Every record carriesengine_version(todayge-decisions/1.1.0), so you can tell which scorer produced it. - Schema. The service has its own OpenAPI document, kept out of any public schema. Invited callers can ask for it.
Authentication
Every call carries a gst1 service token as a bearer credential. It proves which service is calling, which tenant it acts for and that the proof is still fresh.
Authorization: Bearer gst1.<base64url claims>.<base64url signature>
The token is gst1., then the base64url-encoded JSON claims, then a base64url HMAC-SHA256 signature over "gst1." + claims. You do not build tokens yourself: a GodEngine operator issues them, and there is no self-serve token endpoint.
| Claim | Meaning | Format |
|---|---|---|
svc | The calling service. Stored on every record it creates, for attribution. | 1–64 of A-Z a-z 0-9 _ . : -, starting with a letter or digit |
tid | The tenant the token acts for, or * for a broker service that names the tenant on each request | 1–128 of A-Z a-z 0-9 : _ . @ - |
scp | Scopes: decisions:write (POST), decisions:read (GET) | a list of 1–8 names |
iat, exp | Issued-at and expiry | integer unix seconds |
jti | A random token id. It is logged when a token is accepted (the token itself never is) and is how one token is revoked. | 1–64 of A-Z a-z 0-9 _ - |
- Lifetime. 24 hours by default and 7 days at most, with 60 seconds of clock skew allowed. Fetch a fresh token well before expiry rather than holding a long-lived one.
- Revocation. One token can be revoked by its
jtibefore it expires; rotating the signing secret revokes every token at once. A revoked token is a 401. - Checks. The signature is compared in constant time, then every claim is checked against the formats above. A well-signed token with a malformed claim is still a 401.
- Order. The token is checked before the body is read, so an unauthenticated caller learns nothing about the schema.
SecretA gst1 token is a bearer credential until it expires. Keep it out of source control, logs, URLs, error reports and any code that runs in a browser.
Tenants
A tenant id is an organisation id where one exists, otherwise u: followed by a user id. Allowed characters are A-Z a-z 0-9 : _ . @ -, up to 128.
| Token | POST | GET |
|---|---|---|
Concrete (tid = org_a) | Tenant comes from the token. A body tenant_id must match it or be left out; a mismatch is 403. | Tenant comes from the token. An X-Tenant-Id header, if sent, must match; a mismatch is 403. |
Broker (tid = *) | Send tenant_id in the body. Missing is 400. | Send X-Tenant-Id. Missing is 400. |
A malformed tenant id is 422. Reads always filter on the id and the tenant: an unknown id and another tenant's id get the same 404.
POST /v1/decisions
Makes a decision and stores it, or returns the stored record when the idempotency key was used before with the same inputs.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
question | string | yes | 1–2,000 characters. A horizon in it (“over the next 2 years”) is read as the cost horizon. |
options | array of Option | yes | 2–20 options with unique ids |
idempotency_key | string | yes | 1–200 printable ASCII characters, no spaces |
evidence | array of Evidence | no | At most 50 items. Their text and values together are at most 40,000 characters. |
constraints | object | no | Only the keys under constraints; at most 30 keys and about 8,000 characters, nested at most 8 levels |
task_id | string | no | 1–128 of A-Z a-z 0-9 _ . : -. Your task this decision serves; part of the inputs digest. |
tenant_id | string | broker tokens | Up to 128 characters; see tenants |
| Field | Type | Rules |
|---|---|---|
source | string, required | 1–200 characters. Sources the engine read are listed back in evidence_refs. |
text | string | Up to 4,000 characters. Scanned for budgets, deadlines and quotes. |
value | number or string | A string value is at most 200 characters |
unit | string | Up to 40 characters, for example lakh |
From evidence text the engine reads budgets (“budget”, “can spend”, “at most” or “approved” near an amount), deadlines (“within 3 months”) and quotes that name exactly one option. A quote fills a cost the option is missing, and the record notes it. When evidence gives conflicting budgets, the tightest one is used. At most 20 amounts and 20 deadlines are read from any one text, and the record says when a text had more. Evidence is free-form: a value that cannot be read is left out of the model and named in the assumptions.
| Field | Type | Rules |
|---|---|---|
id | string, required | 1–64 of A-Z a-z 0-9 _ . : -; unique in the request |
label | string, required | 1–200 characters |
params | object | At most 40 params. Names up to 64 characters; string values up to 200. Values are scalars or a one-level object such as {"value": 10, "unit": "lakh"}. See option params. |
Responses
- 201 Created, header
Idempotent-Replay: false: a new DecisionRecord. - 200 OK, header
Idempotent-Replay: true: the stored record for a reused key with the same inputs. - 409 Conflict: the key was used with different inputs. See idempotency.
- Errors: see status codes. The body shape is described on the DecisionRecord page.
Constraints
These are the only keys. Any other key, a typo such as budgte included, is 422 at ["body", "constraints", "<key>"].
| Key | Meaning |
|---|---|
budget | A number, a string such as "10 lakh", or {value, unit}. Overrides any budget found in the evidence. A recurring budget ("40k per month") is modelled as its total over the horizon, and the record says so. |
budget_basis | total_over_horizon (default) compares the budget with upfront plus recurring spend over the horizon. upfront compares it with the one-off cost only (a recurring budget with upfront is 422). |
horizon_months or horizon | Overrides the horizon read from the question. Send one, not both. |
deadline_months or deadline | A hard limit on time-to-value. Send one, not both. |
max_risk | A hard limit on the risk level |
risk_tolerance | low, medium or high. Tilts the risk and upside weights. |
weights | {dimension: weight}, then normalised. Dimensions: affordability, upside, risk, speed, reversibility, fit, compliance. Each weight is a number from 0 to 1000; at least one dimension the options are actually scored on must be above zero. |
exclude_options | Up to 20 option ids from this request that you rule out |
currency | The decision's currency. Default: the first currency any input names, else INR. A budget in another currency than an explicit currency is 422. |
A malformed weights or exclude_options is 422 too: a non-object, an unknown dimension, a negative, non-numeric, boolean or non-finite weight, all weights zero, a non-list, or an id that names no option.
Option params
The model reads these params, under any of the listed names. Any other param is reported in modelled_constraints.options.<id>.ignored_params and in the assumptions; it is never dropped silently. A known param whose value cannot be read is 422.
| Param names | Modelled as |
|---|---|
cost, upfront, price, capex, investment | one-off spend |
monthly_cost, opex, rent, emi, subscription; annual_cost | recurring spend |
upside, benefit, impact, value, roi | upside: 0 to 1, a percent, or low / medium / high |
expected_value, expected_revenue | upside, through return against total cost (money) |
risk, downside | risk (higher is worse) |
time_months, time, lead_time, time_to_value | speed; "6 weeks" works |
reversible, reversibility, lock_in | reversibility |
strategic_fit, fit, alignment | fit |
compliance, compliance_risk | compliance |
A dimension that no option gives is left out of the score. A dimension some options give and others do not is filled with a neutral 0.5 for the others, counted in coverage.defaulted_cells.
Amounts
Amounts are read the way people write them. Lakh and crore imply INR. Nothing is converted between currencies: an option priced in another currency is not compared with the budget, and the record says so.
| Input | Value |
|---|---|
10 lakh | 1,000,000 |
45L | 4,500,000 |
₹90L | 9,000,000 |
Rs. 90,00,000 | 9,000,000 |
₹1.2 cr | 12,000,000 |
0.1 crore | 1,000,000 |
50k | 50,000 |
$2.5M | 2,500,000 USD |
30k per month | 30,000 a month |
8.4 lakh/yr | 840,000 a year |
Rs. 10,00,000/- | 1,000,000 |
In free text, a number counts as money only when it carries a currency or a magnitude word, so “over the next 2 years” is read as a horizon. In evidence, a spaced L, m or b needs a currency marker to count as a magnitude, because {"value": 500, "unit": "L"} from a tank gauge is litres.
Money fields are strict
constraints.budget and an option's money params (cost, monthly_cost, annual_cost, expected_value and their other names) must be exactly one amount: a JSON number, a string like those above, or {"value": 10, "unit": "lakh"}. A spaced single letter counts there ("10 L" is ₹10 lakh). Anything else is 422 with the field's location in detail[0].loc, and nothing is computed or stored.
| Value | Why |
|---|---|
"-10 lakh", -1000000 | negative |
"1e6", "10.5.3 lakh" | not one number |
"ten lakh", "approx 45 lakh" | not only an amount |
["10 lakh"], true | not a number or a string |
"2000000000 crore", 1e16 | above the ceiling of 1015 |
The same rule holds for the other known fields: horizon, deadline, max_risk, risk_tolerance, budget_basis, currency, an option's time and its level params such as risk and upside.
GET /v1/decisions/{id}
id: the record's UUID, as returned by the POST.Authorization: a token withdecisions:read.X-Tenant-Id: required with a broker token; with a concrete token it may be left out.
200 returns the record as it was issued. 404 means the record is not found for this tenant; the answer is the same whether the id does not exist or belongs to someone else.
Idempotency
The key travels in the body as idempotency_key. There is no Idempotency-Key header. A key is scoped to the calling service within a tenant: (tenant_id, svc, idempotency_key). Two services in one tenant that both pick order-1 stay independent; neither replays the other's record or gets a 409 because of it. Records are still tenant data, so either service can GET the other's record by id.
| Case | Result |
|---|---|
| Same key, same inputs | 200 with Idempotent-Replay: true and the stored record, byte for byte. The engine does not run again. |
| Same key, different inputs | 409 {"detail": {"error": "idempotency_key_conflict", "message": "…", "decision_id": "…"}}. The stored record is not changed. |
“Same inputs” means the same inputs_digest: a SHA-256 over the canonical question (whitespace collapsed), evidence, options, constraints and task_id when given. The check happens before anything is computed. If two retries race, a unique index settles it and the loser is answered from the winning record. Replays count towards the rate limit.
Validation and limits
| What | Limit | Over it |
|---|---|---|
| Body size | 1 MiB, chunked or not | 413 |
| JSON nesting | 32 levels (8 inside constraints) | 422 |
| Numbers | Finite only: no NaN or Infinity; integers up to 1015 anywhere in the body | 422 |
| Strings | No NUL character and no lone UTF-16 surrogate in any key or value (a valid escaped emoji pair is fine) | 422 |
| Options | 2 to 20, unique ids, at most 40 params each | 422 |
| Evidence | At most 50 items; 40,000 characters of text and values in total | 422 |
| Amounts | At most 1015; never negative in a money field | 422 |
| Durations | Horizon, deadline and option time at most 1,200 months | 422 |
| Unknown fields | Not allowed at the top level, in evidence or option objects, or in constraints. Unknown option params are allowed and reported. | 422 |
The ceilings keep every derived number, such as total spend over the horizon or budget use, finite. As a last check, the store refuses any record that is not strict JSON, so nothing non-finite is ever stored and the idempotency key stays free for a corrected request. Reading a request costs time linear in its size; a worst-case body at the evidence cap is answered in tens of milliseconds.
Rate limits
Writes are limited per tenant and calling service. The default limits are 120 a minute and 3,000 an hour; a deployment can set others. Replays count.
- Over the limit: 429 with
Retry-Afterin seconds. Retry with the same idempotency key. - When the rate limiter is required and unavailable: 503 with
Retry-After: 5.
Status codes
| Status | When |
|---|---|
| 200 | GET found the record; or POST replayed a stored record (Idempotent-Replay: true) |
| 201 | POST made a new record |
| 400 | A broker token did not name the tenant |
| 401 | Missing, malformed, forged, expired or revoked token, or a claim outside the formats |
| 403 | The token lacks the scope, or names a different tenant |
| 404 | The service is not configured, the call did not come from an allowed network, or the record is not found for this tenant |
| 409 | Idempotency key reused with different inputs |
| 413 | Body larger than 1 MiB |
| 422 | Body is not valid JSON, nests too deeply, has an unstorable string, fails the schema, or a known field cannot be read |
| 429 | Over the per-tenant, per-service write limit |
| 503 | The rate limiter is required and unavailable |
Error bodies
Errors use a detail field. Validation errors are a list with a loc path to the field; messages never echo the value you sent.
{
"detail": [
{"type": "value_error", "loc": ["body", "options", 0, "params", "cost"], "msg": "…"}
]
}
{
"detail": [
{"type": "json_invalid", "loc": ["body"],
"msg": "Body must be JSON with finite numbers only, and integers no larger than 10^15"}
]
}