{"openapi":"3.1.0","info":{"title":"Cudatus Relay API","description":"The Global Road Event Network.","version":"1.0.0"},"paths":{"/v1/events":{"post":{"tags":["events"],"summary":"Publish Event","description":"Publish a road event.\n\nIdempotent on (organisation, client_event_id): a retry returns the original event\nwith 200 instead of minting a duplicate, which is what makes this safe to call from a\nvehicle on a flaky link. The replay is detected by the repository, on the event's KEY,\nin the same round trip that would create it — nothing on this path is worth a query.\n\nTHE EVENT INHERITS THE CREDENTIAL'S ENVIRONMENT and there is no way to override it: a\nsandbox key publishes onto the sandbox network, always, whatever the payload says. The\nwire has no `environment` field on the way in for exactly that reason (`extra=forbid`\nwould reject it anyway) — a publisher that could name its own environment is a publisher\nthat can write into the live network from a test key.","operationId":"publish_event_v1_events_post","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventIn"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventPublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["events"],"summary":"List Events","description":"Active events around a point, nearest first.\n\nDistance is measured to an event's full extent, so a queue you are sitting in the\nmiddle of is returned even though it begins kilometres away.\n\nThe caller's ORGANISATION is still unused by the query — it is the seam metering hangs\noff, and resolving it had to happen on day one because that part cannot be retrofitted.\nIts ENVIRONMENT is not decoration either way: a live key is served live events only, a\nsandbox key is served both. See `contract.visible_in`.","operationId":"list_events_v1_events_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"lat","in":"query","required":true,"schema":{"type":"number","maximum":90,"minimum":-90,"description":"Latitude of the search centre","title":"Lat"},"description":"Latitude of the search centre"},{"name":"lon","in":"query","required":true,"schema":{"type":"number","maximum":180,"minimum":-180,"description":"Longitude of the search centre","title":"Lon"},"description":"Longitude of the search centre"},{"name":"radius","in":"query","required":false,"schema":{"type":"number","exclusiveMinimum":0,"description":"Search radius in metres","default":1000,"title":"Radius"},"description":"Search radius in metres"},{"name":"type","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Filter by event type; repeatable","title":"Type"},"description":"Filter by event type; repeatable"},{"name":"min_confidence","in":"query","required":false,"schema":{"type":"number","maximum":1.0,"minimum":0.0,"default":0.0,"title":"Min Confidence"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/EventPublic"},"title":"Response List Events V1 Events Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/events/{event_id}":{"get":{"tags":["events"],"summary":"Get Event","description":"One event by id.\n\n404 also covers \"exists, but not on your network\": a live consumer is never told that a\nsandbox event exists, because an id it cannot be served is, to it, not an id.","operationId":"get_event_v1_events__event_id__get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"event_id","in":"path","required":true,"schema":{"type":"string","title":"Event Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventPublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/events/{event_id}/revisions":{"post":{"tags":["events"],"summary":"Revise Event","description":"Restate where your own event now reaches.\n\nFor the hazard that MOVES rather than the one that sits still: the tail of a queue walks\nupstream, a wrong-way vehicle covers 1.5 km a minute, a fog bank drifts. Send the new\n`geometry` (and `lat`/`lon`, which must stay its first point) and every subscriber gets\nan `updated` delta with the new extent — no new event, no duplicate on the road.\n\nONLY THE PUBLISHER MAY REVISE, and only these fields: `lat`, `lon`, `geometry`,\n`severity`, `subtype`. Everything else is either the network's (the tallies, the\nconfidence, the status) or immutable (`type`, `created_at`, `client_event_id`). If you\nthink somebody ELSE's event has changed, that is a signal — evidence the quorum weighs —\nnot an edit.\n\nTHE FIELDS ARE APPLIED, NOT MERGED. This is a full restatement of the five: omitting\n`severity` clears it, omitting `geometry` collapses the event back to its point. A\nrequest whose absent fields meant \"keep\" could never express a clear, and the two\nreadings are indistinguishable in JSON.\n\nThe expiry is pushed back to a full TTL, capped at the type's ceiling from\n`created_at` — restating an event is evidence it is still there, and the cap is what\nstops that becoming an event that never dies. `wrong_way_vehicle` is exempt and gets no\nextension at all: you may say where it is, you may not keep it alive.\n\n403 for another organisation's event, and for your own event on the other network —\na sandbox key cannot revise a live incident, the same rule signals carry.\n409 when the event is over: a revision must never resurrect a hazard, and an expired\nevent is one to publish again rather than to edit.\n422 when the subtype does not belong to the event's stored type.","operationId":"revise_event_v1_events__event_id__revisions_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"event_id","in":"path","required":true,"schema":{"type":"string","title":"Event Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RevisionIn"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventPublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/events/{event_id}/signals":{"post":{"tags":["events"],"summary":"Signal Event","description":"Confirm or deny an event.\n\nOne signal per REPORTER counts — one vehicle, not one organisation — and the latest one\nis the one that counts: a repeat is accepted (200) and changes nothing at all, a change\nof mind moves that vehicle from one tally to the other. Send `reporter_token` to be\ncounted per vehicle; omit it and the whole organisation counts as one, which is the\nbehaviour that predates reporters and is a degradation rather than a failure.\n\nA denial says \"it is not there any more\", and enough of them END the event early\n(`expired`) rather than mark it false. `invalidated` is a verdict about a publisher and\nis not something a passing vehicle can witness, so this route never produces one.\n\n403 when the caller can see the event but is not on its network — a sandbox credential\nreading a live incident is the designed asymmetry, a sandbox credential VOTING on one is\nthe hole that asymmetry would otherwise open.\n\n409 when the event exists but is over (expired, invalidated, merged): a signal must\nnever resurrect a hazard that is no longer there. Note the fleet consequence — once a\nquorum has cleared an event, every vehicle behind them denying the same cleared hazard\ngets a 409. That is an error status on an expected outcome, and it is the current\nbehaviour rather than a chosen one.\n\nThe reporter's optional position is PERSISTED, not merely accepted: a confirmation\nsent from the scene is not worth the same as one sent 40 km away, and the day the\ntrust score wants to weigh that, it needs the history to compute from.\n\n429 when the organisation has spent its hourly signal ceiling — the ONLY `/v1` route\nwith one, because it is the only one that writes into the trust model. See\n`common/ratelimit.signal_org` for what that ceiling is and is not. A throttled signal is\nstill counted by `metered`, which is the existing rule for a request that authenticated\nand then failed, and it is the right one here: a partner looping into a 429 is exactly\nthe traffic an operator needs to see on the usage page.","operationId":"signal_event_v1_events__event_id__signals_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"event_id","in":"path","required":true,"schema":{"type":"string","title":"Event Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignalIn"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventPublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/meta/event-types":{"get":{"tags":["events"],"summary":"List Event Types","description":"The coarse vocabulary, served from the contract so a client never hardcodes it.\n\nRETURNS A FLAT LIST OF STRINGS, FOREVER. The richer descriptors live at\n`/v1/meta/taxonomy` rather than here, because re-typing a response a client is already\nparsing is precisely what the wire contract forbids.","operationId":"list_event_types_v1_meta_event_types_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"string"},"type":"array","title":"Response List Event Types V1 Meta Event Types Get"}}}}}}},"/v1/meta/taxonomy":{"get":{"tags":["events"],"summary":"Taxonomy","description":"The whole controlled vocabulary — both levels, with each type's lifetime.\n\nThe two levels exist so the coarse one can stay small enough for every consumer to\nimplement while the fine one grows freely: filter on `type`, refine on `subtype`, and\ntreat a subtype you do not recognise as an event of its parent type rather than as an\nevent you cannot read.","operationId":"taxonomy_v1_meta_taxonomy_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaxonomyOut"}}}}}}},"/v1/meta/limits":{"get":{"tags":["events"],"summary":"Limits","description":"Hard limits a client should know before it starts guessing.","operationId":"limits_v1_meta_limits_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/v1/stream/tickets":{"post":{"tags":["stream"],"summary":"Create Stream Ticket","description":"Mint a short-lived credential for a browser subscriber.\n\nRequires the long-lived API key, so this call belongs on a web application's SERVER:\nthe key stays there, only the ticket reaches the browser, and the browser puts the\nticket — never the key — in the EventSource URL. That separation is the entire point;\nsee `api.security.issue_stream_ticket` for the argument and the options rejected.\n\nA ticket carries no scope beyond streaming and no way to publish. It is not returned\ntwice: mint one per connect.","operationId":"create_stream_ticket_v1_stream_tickets_post","responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StreamTicketOut"}}}}},"security":[{"HTTPBearer":[]}]}},"/v1/stream":{"get":{"tags":["stream"],"summary":"Stream","description":"Subscribe to live road events around a point.\n\nEmits `snapshot` once (the current state), then `event` per change, then `keepalive`\nwhile idle and `bye` before closing. A client should treat `snapshot` as authoritative\nand reconnect whenever the connection ends.\n\nAuthenticated by bearer key, or by `?ticket=` for a browser EventSource. The\ncredential is checked HERE, before the response starts — once bytes are on the wire\nthere is no 401 left to send, and an EventSource reads an aborted stream as a normal\nend and retries forever against a road it will never see.","operationId":"stream_v1_stream_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"lat","in":"query","required":true,"schema":{"type":"number","maximum":90,"minimum":-90,"title":"Lat"}},{"name":"lon","in":"query","required":true,"schema":{"type":"number","maximum":180,"minimum":-180,"title":"Lon"}},{"name":"radius","in":"query","required":false,"schema":{"type":"number","exclusiveMinimum":0,"description":"Watch radius in metres","default":1000,"title":"Radius"},"description":"Watch radius in metres"},{"name":"type","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Filter by event type; repeatable","title":"Type"},"description":"Filter by event type; repeatable"},{"name":"min_confidence","in":"query","required":false,"schema":{"type":"number","maximum":1.0,"minimum":0.0,"default":0.0,"title":"Min Confidence"}},{"name":"ticket","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Short-lived stream ticket from POST /v1/stream/tickets. For browser EventSource clients, which cannot set an Authorization header. Every other client should send the API key as a bearer token.","title":"Ticket"},"description":"Short-lived stream ticket from POST /v1/stream/tickets. For browser EventSource clients, which cannot set an Authorization header. Every other client should send the API key as a bearer token."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/auth/request-code":{"post":{"tags":["console:auth"],"summary":"Request Code","description":"Email a one-time code. Creates the account if the address is new.\n\nSIGN-UP AND SIGN-IN ARE THE SAME ROUTE, deliberately. With no password there is nothing\nto choose at registration, so a separate `/register` would differ from this only by\nwhether a row already exists — a distinction the server can make for itself, and one\nthat would otherwise leak: a `/register` that 409s on a known address is an account\nenumerator with a friendly error message.\n\nThe per-EMAIL cap runs BEFORE the lookup and for every address, existing or not, so the\n429 carries no enumeration signal either.","operationId":"request_code_console_auth_request_code_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestCodePayload"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthChallenge"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/auth/verify-code":{"post":{"tags":["console:auth"],"summary":"Verify Code","description":"Complete the email step. Returns a session, or hands off to the TOTP step.\n\nONE GENERIC 400 for an unknown address or a bad/expired code — the two are\nindistinguishable from outside, which is the point.\n\nInbox control does NOT override an enrolled authenticator. A user with TOTP finishes at\n`/verify-totp`, which is what makes the second factor worth anything in a passwordless\nsystem — and the reason this returns a challenge rather than a session. That claim is\nonly as strong as the guessing bound on the TOTP step: the attacker it names already\nholds the inbox, so they mint `mfa` tokens at will and rotate IPs freely, which is why\n`/verify-totp` is capped per ACCOUNT (`totp_verify_user`), not just per IP.","operationId":"verify_code_console_auth_verify_code_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyCodePayload"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Session"},{"$ref":"#/components/schemas/AuthChallenge"}],"title":"Response Verify Code Console Auth Verify Code Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/auth/verify-totp":{"post":{"tags":["console:auth"],"summary":"Verify Totp","description":"Finish a TOTP sign-in. The user comes from the `mfa` token, never the body.\n\nThe per-ACCOUNT cap runs before the check, and it is the one that counts: the attacker\nthis route exists to stop already holds the inbox, so per-IP throttling alone gave them\na fresh guessing window from every address — see `totp_verify_user` in\ncommon/ratelimit.py. The verify is `consume_totp`, so an accepted code is burned and\ncannot be replayed here or at the key-issuance step-up.","operationId":"verify_totp_console_auth_verify_totp_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TotpCodePayload"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/console/auth/verify-recovery":{"post":{"tags":["console:auth"],"summary":"Verify Recovery","description":"Finish a TOTP sign-in with a single-use backup code — the lost-authenticator path.\n\nThere is no password to fall back on and no support route that bypasses TOTP, so this\nis the only way back in. The code is burned on use. Same per-ACCOUNT cap as\n`/verify-totp`, and the two routes share one bucket on purpose: they answer the same\n`mfa` token, so splitting them would double the same attacker's budget.","operationId":"verify_recovery_console_auth_verify_recovery_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecoveryCodePayload"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/console/auth/totp/setup":{"post":{"tags":["console:auth"],"summary":"Totp Setup","description":"Mint an enrolment secret. NOT active until `/totp/confirm`.\n\nRefused while TOTP is already on: re-enrolling would silently replace a working secret\nand lock the owner out of their own authenticator, which a hijacked session could use\nas a lockout attack. Rotating means disabling first, which itself needs a live code.","operationId":"totp_setup_console_auth_totp_setup_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TotpEnrolment"}}}}},"security":[{"HTTPBearer":[]}]}},"/console/auth/totp/confirm":{"post":{"tags":["console:auth"],"summary":"Totp Confirm","description":"Prove the authenticator works, enable TOTP, and mint the backup codes.\n\nThe codes are returned in the clear THIS ONCE; only their hashes are stored. They are\nthe sole escape from a lost authenticator on an account with no password, which is why\nthey are generated here rather than offered as an optional extra step someone skips.","operationId":"totp_confirm_console_auth_totp_confirm_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TotpCodePayload"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecoveryCodes"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/console/auth/totp/disable":{"post":{"tags":["console:auth"],"summary":"Totp Disable","description":"Turn TOTP off. Requires a current code, and ends every other session.\n\nTwo guards, and both are aimed at the same attacker. Requiring a live code means a\nhijacked session cannot downgrade the account in one click. Revoking every session\n(inside `set_totp_enabled`) means that if the downgrade DOES happen, the attacker's own\nsession dies with it — they must re-enter through the inbox, which they do not hold.\nWe re-mint the caller's token so the tab that performed the change stays signed in; its\n`iat` lands in the same second as the watermark, which the deliberate one-second window\nin `sessions.is_revoked` lets through.\n\nVerify BEFORE disabling: `set_totp_enabled(False)` discards the secret.","operationId":"totp_disable_console_auth_totp_disable_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TotpCodePayload"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/console/auth/recovery-codes":{"get":{"tags":["console:auth"],"summary":"Recovery Codes Status","description":"How many backup codes remain — drives the console's \"N left / regenerate\".","operationId":"recovery_codes_status_console_auth_recovery_codes_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecoveryCodesStatus"}}}}},"security":[{"HTTPBearer":[]}]}},"/console/auth/recovery-codes/regenerate":{"post":{"tags":["console:auth"],"summary":"Regenerate Recovery Codes","description":"A fresh set, invalidating the old one. Requires a live authenticator code.\n\nStep-up with the very factor being protected: the person regenerating must hold the\nauthenticator, so a hijacked session cannot cycle the backup set and lock the real\nowner out of their own escape hatch.","operationId":"regenerate_recovery_codes_console_auth_recovery_codes_regenerate_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TotpCodePayload"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecoveryCodes"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/console/auth/me":{"get":{"tags":["console:auth"],"summary":"Me","description":"Who is signed in, and to which organisations.\n\nReturns a Session — including a FRESHLY MINTED token — rather than a bare profile, so\nthe console can use this one call both to bootstrap on load and to slide the expiry\nforward on an active user. A thirty-day session that silently dies mid-work on day\nthirty-one is worse than a shorter one that renews.","operationId":"me_console_auth_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}}},"security":[{"HTTPBearer":[]}]},"patch":{"tags":["console:auth"],"summary":"Update Me","description":"Edit the profile. Only the fields explicitly sent are written.","operationId":"update_me_console_auth_me_patch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMePayload"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MePublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/console/auth/logout":{"post":{"tags":["console:auth"],"summary":"Logout","description":"Sign out everywhere — stamp the watermark that kills every outstanding token.\n\nThe console clears its own storage regardless; this is what makes logout mean something\nserver-side for a token that has been copied somewhere else.","operationId":"logout_console_auth_logout_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}}},"security":[{"HTTPBearer":[]}]}},"/console/orgs":{"post":{"tags":["console:orgs"],"summary":"Create Org","description":"Create an organisation. The caller becomes its owner.\n\nRate-limited because an organisation is free, though no longer for the reason this\ndocstring used to give: the trust quorum counts REPORTERS rather than organisations, so\nbulk org creation is not the Sybil surface any more — one org minting reporter tokens\nis (see `common/trust.py`). The limit stays as ordinary abuse hygiene, not as a defence\nof the trust model.","operationId":"create_org_console_orgs_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrgPayload"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgPublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/console/orgs/{org_id}":{"get":{"tags":["console:orgs"],"summary":"Get Org","operationId":"get_org_console_orgs__org_id__get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgPublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["console:orgs"],"summary":"Update Org","operationId":"update_org_console_orgs__org_id__patch","security":[{"HTTPBearer":[]}],"parameters":[{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOrgPayload"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgPublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/orgs/{org_id}/members":{"get":{"tags":["console:orgs"],"summary":"List Members","description":"Members and pending invitations, in one response.\n\nOne payload rather than two routes because they are one list on screen — a seat is a\nseat whether the person has accepted yet or not, and splitting them would make the\nconsole reconcile two fetches to render one table.","operationId":"list_members_console_orgs__org_id__members_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Members Console Orgs  Org Id  Members Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["console:orgs"],"summary":"Invite Member","description":"Invite someone by email.\n\nTwo rate limits, not one: per ACTOR (a hijacked owner session must not spray\ninvitations) and per RECIPIENT ADDRESS (this route sends mail to an address the actor\nchose, which is the same bombing surface as the sign-in code, reached from inside a\nsession).\n\nThe seat cap is checked against members AND live invitations together — counting only\naccepted members would let an organisation invite past its plan and discover the limit\nat the moment each person tries to join.","operationId":"invite_member_console_orgs__org_id__members_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitePayload"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitePublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/orgs/{org_id}/members/{user_id}":{"patch":{"tags":["console:orgs"],"summary":"Update Member","description":"Change someone's role. Refuses to demote the last owner — see the module docstring.","operationId":"update_member_console_orgs__org_id__members__user_id__patch","security":[{"HTTPBearer":[]}],"parameters":[{"name":"user_id","in":"path","required":true,"schema":{"type":"string","title":"User Id"}},{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberPayload"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberPublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["console:orgs"],"summary":"Remove Member","description":"Remove someone. Refuses to remove the last owner.\n\nREMOVING A MEMBER DOES NOT REVOKE THE ORGANISATION'S KEYS, and that is correct rather\nthan an oversight: keys belong to the ORGANISATION, not to the person who happened to\nclick issue. Revoking on departure would break a running integration every time a\ncolleague leaves. Rotating keys after an offboarding is a decision for the remaining\nowners, and the console gives them the list, the issuance dates and the audit log to\nmake it — which is the information the automatic behaviour would have hidden.","operationId":"remove_member_console_orgs__org_id__members__user_id__delete","security":[{"HTTPBearer":[]}],"parameters":[{"name":"user_id","in":"path","required":true,"schema":{"type":"string","title":"User Id"}},{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/orgs/{org_id}/leave":{"post":{"tags":["console:orgs"],"summary":"Leave Org","description":"Leave the organisation. Any member may — a seat is theirs to give up, no owner asked.\n\nRefuses to let the last owner leave, same rule as removal and demotion (module\ndocstring): an organisation with zero owners is bricked. `org_context` already resolved\nthe caller's membership, so a caller without one was a 404 before this ran; the\nre-fetch below only guards the race where the seat vanished since.","operationId":"leave_org_console_orgs__org_id__leave_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/orgs/{org_id}/invitations/{invite_id}":{"delete":{"tags":["console:orgs"],"summary":"Revoke Invitation","description":"Withdraw a pending invitation. Resolved within the caller's org, so a guessed id\nfrom elsewhere is simply not found.","operationId":"revoke_invitation_console_orgs__org_id__invitations__invite_id__delete","security":[{"HTTPBearer":[]}],"parameters":[{"name":"invite_id","in":"path","required":true,"schema":{"type":"string","title":"Invite Id"}},{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/orgs/{org_id}/invitations/{invite_id}/resend":{"post":{"tags":["console:orgs"],"summary":"Resend Invitation","description":"Send an invitation again, to the same address and role.\n\nTHE `invite_id` CHANGES. A resend is a fresh mint: `invites_repo.create` retires any\nearlier pending invitation for the address, so rotating the token never leaves two live\ndoors for one seat — which is also why this route needed no repository change. A caller\nholding the old id must take the new one from the response.\n\nNo seat-cap check: the seat is already counted, a resend adds none.","operationId":"resend_invitation_console_orgs__org_id__invitations__invite_id__resend_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"invite_id","in":"path","required":true,"schema":{"type":"string","title":"Invite Id"}},{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitePublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/invitations/accept":{"post":{"tags":["console:orgs"],"summary":"Accept Invitation","description":"Accept an invitation. Requires a signed-in session whose address matches the invite.\n\nTHE ADDRESS CHECK IS THE WHOLE SECURITY OF THIS ROUTE. The token travels by email, so\nanyone who reads or is forwarded that mail holds it. Binding acceptance to the invited\naddress means holding the token is not sufficient — the accepter must also have proved\ncontrol of that inbox, which the sign-in flow already required of them.\n\nNot scoped by `org_context`: the caller is by definition not yet a member.","operationId":"accept_invitation_console_invitations_accept_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AcceptInvitePayload"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgPublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/console/orgs/{org_id}/audit":{"get":{"tags":["console:orgs"],"summary":"List Audit","description":"The organisation's audit log, newest first.\n\nReadable by any MEMBER, not just owners. An audit log exists to be read by the people\nwho would notice something wrong, and restricting it to the role most likely to have\nbeen compromised is the wrong instinct.","operationId":"list_audit_console_orgs__org_id__audit_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AuditEntryPublic"},"title":"Response List Audit Console Orgs  Org Id  Audit Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/orgs/{org_id}/keys":{"get":{"tags":["console:keys"],"summary":"List Keys","description":"The organisation's keys. Secrets are not here and cannot be recovered.\n\nRevoked keys are INCLUDED by default. \"This key existed and was revoked on that date\"\nis exactly what someone reconstructing an incident needs, and a list that hides them\nanswers the wrong question — the console greys them out rather than dropping them.","operationId":"list_keys_console_orgs__org_id__keys_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}},{"name":"environment","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment"}},{"name":"include_revoked","in":"query","required":false,"schema":{"type":"boolean","default":true,"title":"Include Revoked"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiKeyPublic"},"title":"Response List Keys Console Orgs  Org Id  Keys Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["console:keys"],"summary":"Create Key","description":"Issue a key. Returns the raw value ONCE — we store only its sha256.\n\nThe guards run in this order, and the order is the point: cheapest and most specific\nfirst, so a hijacked session hits the step-up before it costs us a rate-limit slot or a\nplan lookup.","operationId":"create_key_console_orgs__org_id__keys_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateKeyPayload"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssuedKey"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/orgs/{org_id}/keys/{key_id}":{"delete":{"tags":["console:keys"],"summary":"Revoke Key","description":"Revoke a key. Effective on the next request that presents it.\n\n`api/security.resolve_org` has honoured the `revoked` flag since before there was a\nconsole — nothing ever flipped it. This is that switch finally getting a hand on it.\n\nNO STEP-UP, deliberately, and the asymmetry with issuance is the argument: revocation\nis destructive but not CREATIVE. Its worst case is an outage the owners can fix by\nissuing another key; issuance's worst case is a credential in an attacker's hands that\nnobody notices. Requiring an authenticator code to stop a leak is friction in exactly\nthe moment when speed matters most.\n\nResolved WITHIN the caller's organisation, so a `key_id` from another org is not found\nrather than refused — the authorisation is structural.","operationId":"revoke_key_console_orgs__org_id__keys__key_id__delete","security":[{"HTTPBearer":[]}],"parameters":[{"name":"key_id","in":"path","required":true,"schema":{"type":"string","title":"Key Id"}},{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/orgs/{org_id}/usage":{"get":{"tags":["console:activity"],"summary":"Get Usage","description":"A dense daily series for one environment.\n\nDense — every day in the window present, zeros included — because a sparse series makes\nthe client guess which days are missing, and a line drawn through a gap reads as\n\"traffic continued\". `usage_daily.fetch_range` fills it server-side, once.","operationId":"get_usage_console_orgs__org_id__usage_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}},{"name":"window","in":"query","required":false,"schema":{"type":"string","description":"7d | 30d | 90d","default":"30d","title":"Window"},"description":"7d | 30d | 90d"},{"name":"environment","in":"query","required":false,"schema":{"type":"string","default":"live","title":"Environment"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageSeries"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/orgs/{org_id}/vitality":{"get":{"tags":["console:activity"],"summary":"Get Vitality","description":"Is this integration connected, and when did we last hear from it?\n\nRANKED FIRST ON THE CONSOLE'S LANDING SCREEN, because it is the question behind every\ninfrastructure support ticket — \"we integrated, is anything arriving?\" — and because\nnothing else on any page answers it.\n\nEvery field is derived from data that exists: `last_used_at` per key (stamped, throttled,\nby `usage.bump`) and today's counters. An organisation that has never called us gets\nnulls and `has_traffic: false`, which the console renders as the onboarding runway\nrather than as a dashboard of zeros.","operationId":"get_vitality_console_orgs__org_id__vitality_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}},{"name":"environment","in":"query","required":false,"schema":{"type":"string","default":"live","title":"Environment"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Vitality Console Orgs  Org Id  Vitality Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/orgs/{org_id}/billing":{"get":{"tags":["console:activity"],"summary":"Get Billing","description":"The current calendar month, the plan, and the org's standing with Stripe.\n\nThis docstring used to open \"NO INVOICE, NO PAYMENT METHOD, NO PROVIDER — nothing\ncharges yet\". The provider exists now (`common/billing/`, Stripe), and what stands\nof the old argument is its second half: there is still no invoice list HERE, because\ninvoice history and the payment method live in the Stripe customer portal —\nStripe's UI over Stripe's data, reached through `POST .../billing/portal`. What this\nreturns is the plan, the month's REAL usage, and two booleans the console's buttons\nbranch on (`billing_configured`, `subscription_active`).\n\nMEMBER-readable while the checkout/portal routes are owner-only: what the month\ncosts is the explanation for the dashboard every seat is looking at.\n\nLIVE ONLY. Sandbox traffic is not billable and never will be, so mixing it into a\nbilling figure would misstate the one number on this page that is supposed to be\nmoney.","operationId":"get_billing_console_orgs__org_id__billing_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingSummary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/plans":{"get":{"tags":["console:activity"],"summary":"List Plans","description":"The catalog. Open — it is the same information as the public pricing page, and\nrequiring a session to read a price list would be a strange thing to do.","operationId":"list_plans_console_plans_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/PlanPublic"},"type":"array","title":"Response List Plans Console Plans Get"}}}}}}},"/console/orgs/{org_id}/billing/checkout":{"post":{"tags":["console:billing"],"summary":"Create Checkout","description":"Start a subscription Checkout for this organisation (owner only).\n\nThe body names a plan id from `common/plans.py`, never a Stripe price id — the\nprice is resolved server-side, so a client cannot check out onto a price we never\npublished. Free and enterprise are refused there too (400), an already-subscribed\norg is sent to the portal (409), and unconfigured billing answers 503 naming the\nvariable to fill.","operationId":"create_checkout_console_orgs__org_id__billing_checkout_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutPayload"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingSessionPublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/console/orgs/{org_id}/billing/portal":{"post":{"tags":["console:billing"],"summary":"Create Portal","description":"Open the Stripe customer portal for this organisation (owner only).\n\nPlan changes, cancellation, payment method and invoice history all live there —\nStripe's UI over Stripe's data, landing back here as webhooks. 409 when the org has\nno billing account yet (choose a plan first), 503 when billing is unconfigured.","operationId":"create_portal_console_orgs__org_id__billing_portal_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"org_id","in":"path","required":true,"schema":{"type":"string","title":"Org Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingSessionPublic"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AcceptInvitePayload":{"properties":{"token":{"type":"string","maxLength":200,"minLength":1,"title":"Token"}},"type":"object","required":["token"],"title":"AcceptInvitePayload"},"ApiKeyPublic":{"properties":{"key_id":{"type":"string","title":"Key Id"},"label":{"type":"string","title":"Label"},"environment":{"type":"string","title":"Environment"},"prefix":{"type":"string","title":"Prefix"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"created_by_email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created By Email"},"last_used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Used At"},"revoked":{"type":"boolean","title":"Revoked","default":false},"revoked_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Revoked At"}},"type":"object","required":["key_id","label","environment","prefix"],"title":"ApiKeyPublic","description":"A key as the console lists it. The secret is not here and cannot be recovered.\n\n`prefix` is the first characters of the raw key, kept so a human can match a row in\nthis list against the value in a config file without revealing anything: the key is 43\nrandom characters and this shows a dozen of them."},"AuditEntryPublic":{"properties":{"audit_id":{"type":"string","title":"Audit Id"},"action":{"type":"string","title":"Action"},"actor_user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Actor User Id"},"actor_email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Actor Email"},"target":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target"},"detail":{"additionalProperties":true,"type":"object","title":"Detail","default":{}},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"}},"type":"object","required":["audit_id","action"],"title":"AuditEntryPublic","description":"One consequential action. `actor_email` is denormalised at write time on purpose:\nan audit line that stops naming its actor once that user is removed from the\norganisation answers the wrong half of \"who did what\"."},"AuthChallenge":{"properties":{"method":{"type":"string","enum":["email","totp"],"title":"Method"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"temp_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Temp Token"}},"type":"object","required":["method"],"title":"AuthChallenge","description":"A step was completed and another is required — deliberately NOT a session.\n\n`method` says which. `email` echoes where the code went (so the UI can render \"we sent\na code to a@b.c\" without trusting its own form state). `temp_token` carries the `mfa`\nbridge token for the TOTP step, and is absent for an email challenge because there is\nnothing to bridge yet."},"BillingSessionPublic":{"properties":{"url":{"type":"string","title":"Url"}},"type":"object","required":["url"],"title":"BillingSessionPublic","description":"A Stripe-hosted page to send the browser to. The URL is the whole payload."},"BillingSummary":{"properties":{"org_id":{"type":"string","title":"Org Id"},"plan":{"$ref":"#/components/schemas/PlanPublic"},"period_start":{"type":"string","title":"Period Start"},"period_end":{"type":"string","title":"Period End"},"totals":{"$ref":"#/components/schemas/UsageTotals"},"included_reads":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Included Reads"},"billing_configured":{"type":"boolean","title":"Billing Configured","default":false},"subscription_active":{"type":"boolean","title":"Subscription Active","default":false}},"type":"object","required":["org_id","plan","period_start","period_end","totals"],"title":"BillingSummary","description":"What the billing page renders: the plan and this month's measured usage, plus the\norganisation's standing with Stripe.\n\nNO INVOICE LIST HERE, and that is a decision, not a gap: invoice history and the\npayment method live in the Stripe customer portal (`POST .../billing/portal`), which\nis Stripe's UI over Stripe's data. Rendering our own copy would be a second invoice\nsurface to keep truthful. See docs/internal/billing.mdx."},"CheckoutPayload":{"properties":{"plan":{"type":"string","maxLength":40,"minLength":1,"title":"Plan"}},"type":"object","required":["plan"],"title":"CheckoutPayload","description":"Start a Checkout. Names a PLAN ID from `common/plans.py`, never a Stripe price id\n— the price is resolved server-side, or a client checks out onto a price we never\npublished."},"CreateKeyPayload":{"properties":{"label":{"type":"string","maxLength":120,"minLength":1,"title":"Label"},"environment":{"type":"string","enum":["live","sandbox"],"title":"Environment","default":"sandbox"},"totp_code":{"anyOf":[{"type":"string","maxLength":16},{"type":"null"}],"title":"Totp Code"}},"type":"object","required":["label"],"title":"CreateKeyPayload"},"CreateOrgPayload":{"properties":{"name":{"type":"string","maxLength":80,"minLength":1,"title":"Name"}},"type":"object","required":["name"],"title":"CreateOrgPayload"},"EventIn":{"properties":{"type":{"type":"string","enum":["accident","stopped_vehicle","object_on_road","weather_hazard","congestion","wrong_way_vehicle","road_damage","people_on_road","police_check","speed_camera","road_closure","infrastructure_fault","roadworks"],"title":"Type"},"subtype":{"anyOf":[{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,64}$"},{"type":"null"}],"title":"Subtype"},"lat":{"type":"number","maximum":90.0,"minimum":-90.0,"title":"Lat"},"lon":{"type":"number","maximum":180.0,"minimum":-180.0,"title":"Lon"},"geometry":{"anyOf":[{"$ref":"#/components/schemas/LineString"},{"type":"null"}]},"heading":{"anyOf":[{"type":"integer","maximum":359.0,"minimum":0.0},{"type":"null"}],"title":"Heading"},"road_ref":{"anyOf":[{"type":"string","maxLength":32},{"type":"null"}],"title":"Road Ref"},"severity":{"anyOf":[{"type":"string","enum":["low","medium","high"]},{"type":"null"}],"title":"Severity"},"description":{"anyOf":[{"type":"string","maxLength":280},{"type":"null"}],"title":"Description"},"occurred_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Occurred At"},"client_event_id":{"anyOf":[{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,64}$"},{"type":"null"}],"title":"Client Event Id"},"reporter_token":{"anyOf":[{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,64}$"},{"type":"null"}],"title":"Reporter Token"}},"additionalProperties":false,"type":"object","required":["type","lat","lon"],"title":"EventIn","description":"What a publisher sends to POST /events. Exactly the contract's wire layer."},"EventPublic":{"properties":{"event_id":{"type":"string","title":"Event Id"},"schema_version":{"type":"integer","title":"Schema Version"},"type":{"type":"string","title":"Type","description":"One of GET /v1/meta/event-types. The vocabulary grows: treat a value you do not know as an opaque event."},"lat":{"type":"number","title":"Lat"},"lon":{"type":"number","title":"Lon"},"geometry":{"anyOf":[{"$ref":"#/components/schemas/LineString"},{"type":"null"}]},"heading":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Heading"},"road_ref":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Road Ref"},"severity":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Severity"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"occurred_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Occurred At"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"expires_at":{"type":"string","format":"date-time","title":"Expires At"},"source_kind":{"type":"string","title":"Source Kind"},"feed_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Feed Id"},"license":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"License"},"upstream_updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Upstream Updated At"},"fetched_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Fetched At"},"status":{"type":"string","title":"Status"},"verification":{"type":"string","title":"Verification"},"confidence":{"type":"number","title":"Confidence"},"confirm_count":{"type":"integer","title":"Confirm Count"},"deny_count":{"type":"integer","title":"Deny Count"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"merged_into":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Merged Into"},"environment":{"type":"string","title":"Environment","default":"live"},"subtype":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Subtype"}},"type":"object","required":["event_id","schema_version","type","lat","lon","created_at","expires_at","source_kind","status","verification","confidence","confirm_count","deny_count"],"title":"EventPublic","description":"What the API returns. Additive forever — fields are only ever appended here.\n\nPermissive on the vocabularies for the same reason EventRecord is: a `Literal` here\nwould move the rolling-deploy crash from the read to the re-serialisation and fix\nnothing. An event whose `type` we cannot interpret is SERVED rather than skipped —\ndropping it would make the network look empty during a deploy, which is indetectable,\nwhere an opaque event is at least visible. The client rule that pairs with it is\nwritten into the contract header: unknown value means skip the event, never crash."},"EventTypeSpec":{"properties":{"id":{"type":"string","title":"Id"},"subtypes":{"items":{"type":"string"},"type":"array","title":"Subtypes","description":"The fine vocabulary under this type. May be empty, and GROWS WITHOUT NOTICE — read it, do not hardcode it, and never filter on a subtype you enumerated once."},"ttl_seconds":{"type":"integer","title":"Ttl Seconds","description":"How long an event of this type is served with no further evidence."},"max_ttl_seconds":{"type":"integer","title":"Max Ttl Seconds","description":"The hard ceiling on its lifetime, however many confirmations arrive. Well above ttl_seconds for a type that is not really ephemeral — `road_damage` is the one today."}},"type":"object","required":["id","subtypes","ttl_seconds","max_ttl_seconds"],"title":"EventTypeSpec","description":"One coarse type, as `GET /v1/meta/taxonomy` publishes it."},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"InvitePayload":{"properties":{"email":{"type":"string","format":"email","title":"Email"},"role":{"type":"string","enum":["owner","member"],"title":"Role","default":"member"}},"type":"object","required":["email"],"title":"InvitePayload"},"InvitePublic":{"properties":{"invite_id":{"type":"string","title":"Invite Id"},"email":{"type":"string","title":"Email"},"role":{"type":"string","title":"Role"},"invited_by_email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Invited By Email"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"}},"type":"object","required":["invite_id","email","role"],"title":"InvitePublic","description":"A pending invitation. `token` is NEVER included — it is emailed, and a member who\ncan list invites must not be able to accept one addressed to someone else."},"IssuedKey":{"properties":{"key":{"type":"string","title":"Key"},"api_key":{"$ref":"#/components/schemas/ApiKeyPublic"}},"type":"object","required":["key","api_key"],"title":"IssuedKey","description":"The one response in this file that carries a secret. Shown once, never again."},"LineString":{"properties":{"type":{"type":"string","const":"LineString","title":"Type","default":"LineString"},"coordinates":{"items":{"prefixItems":[{"type":"number"},{"type":"number"}],"type":"array","maxItems":2,"minItems":2},"type":"array","maxItems":512,"minItems":2,"title":"Coordinates"}},"type":"object","required":["coordinates"],"title":"LineString","description":"GeoJSON LineString — the extent of a segment event.\n\nWATCH THE AXIS ORDER. GeoJSON is [longitude, latitude], the reverse of how every\nother field here reads and the single most common way to put an event in the wrong\ncountry. We keep GeoJSON's order rather than inventing our own, because partners\npaste geometry straight out of their existing tooling."},"MePublic":{"properties":{"user_id":{"type":"string","title":"User Id"},"email":{"type":"string","title":"Email"},"display_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Display Name"},"locale":{"type":"string","title":"Locale","default":"en"},"totp_enabled":{"type":"boolean","title":"Totp Enabled","default":false},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"last_seen_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Seen At"}},"type":"object","required":["user_id","email"],"title":"MePublic"},"MemberPublic":{"properties":{"user_id":{"type":"string","title":"User Id"},"email":{"type":"string","title":"Email"},"display_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Display Name"},"role":{"type":"string","title":"Role"},"joined_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Joined At"},"last_seen_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Seen At"}},"type":"object","required":["user_id","email","role"],"title":"MemberPublic"},"OkResponse":{"properties":{"ok":{"type":"boolean","title":"Ok","default":true}},"type":"object","title":"OkResponse"},"OrgMembershipPublic":{"properties":{"org_id":{"type":"string","title":"Org Id"},"name":{"type":"string","title":"Name"},"plan":{"type":"string","title":"Plan"},"role":{"type":"string","title":"Role"}},"type":"object","required":["org_id","name","plan","role"],"title":"OrgMembershipPublic","description":"An org as seen from the user's side — what the switcher renders."},"OrgPublic":{"properties":{"org_id":{"type":"string","title":"Org Id"},"name":{"type":"string","title":"Name"},"plan":{"type":"string","title":"Plan"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"}},"type":"object","required":["org_id","name","plan"],"title":"OrgPublic"},"PlanPublic":{"properties":{"id":{"type":"string","title":"Id"},"label":{"type":"string","title":"Label"},"included_reads":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Included Reads"},"max_live_keys":{"type":"integer","title":"Max Live Keys"},"max_members":{"type":"integer","title":"Max Members"},"monthly_cents":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Monthly Cents"},"support":{"type":"string","title":"Support"}},"type":"object","required":["id","label","max_live_keys","max_members","support"],"title":"PlanPublic"},"RecoveryCodePayload":{"properties":{"code":{"type":"string","maxLength":32,"minLength":1,"title":"Code"}},"type":"object","required":["code"],"title":"RecoveryCodePayload"},"RecoveryCodes":{"properties":{"recovery_codes":{"items":{"type":"string"},"type":"array","title":"Recovery Codes"}},"type":"object","required":["recovery_codes"],"title":"RecoveryCodes","description":"Shown ONCE. Only hashes are stored; there is no path that can show them again.\n\nWith no password in the system, these are the ONLY way back into an account whose\nauthenticator is lost — the console says so in the same breath as it shows them."},"RecoveryCodesStatus":{"properties":{"generated":{"type":"integer","title":"Generated"},"remaining":{"type":"integer","title":"Remaining"}},"type":"object","required":["generated","remaining"],"title":"RecoveryCodesStatus"},"RequestCodePayload":{"properties":{"email":{"type":"string","format":"email","title":"Email"},"locale":{"type":"string","title":"Locale","default":"en"}},"type":"object","required":["email"],"title":"RequestCodePayload","description":"Ask for a one-time sign-in code. Answers identically whether or not the account\nexists — see api/console/auth.py on enumeration."},"RevisionIn":{"properties":{"lat":{"type":"number","maximum":90.0,"minimum":-90.0,"title":"Lat"},"lon":{"type":"number","maximum":180.0,"minimum":-180.0,"title":"Lon"},"geometry":{"anyOf":[{"$ref":"#/components/schemas/LineString"},{"type":"null"}]},"subtype":{"anyOf":[{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,64}$"},{"type":"null"}],"title":"Subtype"},"severity":{"anyOf":[{"type":"string","enum":["low","medium","high"]},{"type":"null"}],"title":"Severity"}},"additionalProperties":false,"type":"object","required":["lat","lon"],"title":"RevisionIn","description":"POST /events/{id}/revisions — the publisher restating where its own event now IS.\n\nTHE FIELD SET IS THE WHOLE DESIGN, and it is deliberately narrow: WHERE the event is\n(`lat`, `lon`, `geometry`) and HOW BAD (`severity`, `subtype`). Nothing else.\n\nIt exists because a queue is not a fixed segment, it is a segment that MOVES — the tail\nof a jam walks upstream kilometres in minutes, and `congestion` is the type the\n`geometry` field was added for. Until this route, the only way to say so was a new\n`client_event_id`: at a 30 min TTL and a one-minute cadence that is up to thirty\noverlapping events for one bouchon, per publisher, and `geo7` is empty for long segments\nby construction (`geo.cells_for_event`), so no dedup would ever have collapsed them.\nThe other half of the reason is an asymmetry: `upsert_public` has always been able to\nrevise a feed event's extent, so the DATEX connector could grow its queues and a paying\npartner could not.\n\nWHAT IS NOT HERE, and why each one is out rather than forgotten:\n  - `type`. The hub filters subscriptions on it, so re-typing an event mid-life would\n    move it between subscriber sets and change its TTL under a `created_at` the network\n    already believed. A different type is a different event.\n  - `heading`, `road_ref`, `description`. They describe what was observed, not where it\n    currently reaches, and a route that can rewrite free text is a moderation surface —\n    see hole 0b for what an editable string on the wire costs when there is no undo.\n  - `occurred_at`, `client_event_id`, `reporter_token`. Trust anchors and identity: the\n    first is immutable by contract, the second IS the event's identity under\n    `_event_id_for`, and the third would let a revision mint a vote."},"Session":{"properties":{"token":{"type":"string","title":"Token"},"expires_at":{"type":"string","format":"date-time","title":"Expires At"},"user":{"$ref":"#/components/schemas/MePublic"},"orgs":{"items":{"$ref":"#/components/schemas/OrgMembershipPublic"},"type":"array","title":"Orgs","default":[]}},"type":"object","required":["token","expires_at","user"],"title":"Session","description":"A signed-in user. The token goes in `Authorization: Bearer` on /console routes only.\n\nIt is NOT an API key and the API will not accept it as one: `/v1` resolves credentials\nthrough `api/security.py`, which looks its bearer up in the `ApiKey` kind and will\nnever find a JWT there."},"SignalIn":{"properties":{"action":{"type":"string","enum":["confirm","deny"],"title":"Action"},"lat":{"anyOf":[{"type":"number","maximum":90.0,"minimum":-90.0},{"type":"null"}],"title":"Lat"},"lon":{"anyOf":[{"type":"number","maximum":180.0,"minimum":-180.0},{"type":"null"}],"title":"Lon"},"client_event_id":{"anyOf":[{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,64}$"},{"type":"null"}],"title":"Client Event Id"},"reporter_token":{"anyOf":[{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,64}$"},{"type":"null"}],"title":"Reporter Token"}},"additionalProperties":false,"type":"object","required":["action"],"title":"SignalIn","description":"POST /events/{id}/signals — confirm or deny an existing event.\n\nThe reporter's own position is optional but valuable: a confirmation sent from 40 km\naway is not worth the same as one sent from the scene, and keeping the field lets the\ntrust score use that later without a wire change.\n\n`client_event_id` is accepted and unused: signalling is already idempotent by key —\none signal entity per (event_id, reporter), latest action wins — so a retry needs no\ntoken. It stays on the wire because removing a field a client may already send is\nexactly what \"the contract is forever\" forbids."},"StreamTicketOut":{"properties":{"ticket":{"type":"string","title":"Ticket"},"expires_at":{"type":"string","format":"date-time","title":"Expires At"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["ticket","expires_at","expires_in"],"title":"StreamTicketOut","description":"POST /v1/stream/tickets — the short-lived credential a browser subscribes with.\n\nA model rather than a bare dict for two reasons, and the second one is the real one:\nit gives the endpoint an OpenAPI schema (this is the one route a browser integration\nMUST call, so it should not be the one route with no documented shape), and it puts\n`expires_at` through the same Pydantic serialiser as every event timestamp.\n\nThat second point was a live inconsistency: the route returned a plain dict, so\nFastAPI's encoder emitted `…+00:00` while every event field emitted `…Z`. Both are\nvalid RFC 3339 and a client parsing with a fixed format string trips on exactly one\nof them — the kind of divergence that is free to remove now and permanent later."},"TaxonomyOut":{"properties":{"schema_version":{"type":"integer","title":"Schema Version"},"types":{"items":{"$ref":"#/components/schemas/EventTypeSpec"},"type":"array","title":"Types"}},"type":"object","required":["schema_version","types"],"title":"TaxonomyOut","description":"`GET /v1/meta/taxonomy` — the whole controlled vocabulary, both levels.\n\nA MODEL RATHER THAN A DICT, for the reason StreamTicketOut gives below: this is the\nroute whose entire job is to stop a client hardcoding a vocabulary, so it must not be\nthe route with no documented shape.\n\nIt is a SECOND endpoint rather than a richer `/v1/meta/event-types`, and that is the\ncontract rule rather than taste — that route returns `list[str]` and will return\n`list[str]` forever. Re-typing a response is exactly what \"the contract is forever\"\nforbids, and a client pinned to the flat list must keep working with no change."},"TotpCodePayload":{"properties":{"code":{"type":"string","maxLength":16,"minLength":1,"title":"Code"}},"type":"object","required":["code"],"title":"TotpCodePayload"},"TotpEnrolment":{"properties":{"secret":{"type":"string","title":"Secret"},"provisioning_uri":{"type":"string","title":"Provisioning Uri"}},"type":"object","required":["secret","provisioning_uri"],"title":"TotpEnrolment","description":"Enrolment material, returned once, BEFORE the factor is active. Not yet enabled —\n`POST /console/auth/totp/confirm` with a working code is what enables it."},"UpdateMePayload":{"properties":{"display_name":{"anyOf":[{"type":"string","maxLength":80},{"type":"null"}],"title":"Display Name"},"locale":{"anyOf":[{"type":"string","enum":["en","fr"]},{"type":"null"}],"title":"Locale"}},"type":"object","title":"UpdateMePayload"},"UpdateMemberPayload":{"properties":{"role":{"type":"string","enum":["owner","member"],"title":"Role"}},"type":"object","required":["role"],"title":"UpdateMemberPayload"},"UpdateOrgPayload":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":80},{"type":"null"}],"title":"Name"}},"type":"object","title":"UpdateOrgPayload"},"UsagePoint":{"properties":{"day":{"type":"string","title":"Day"},"publish":{"type":"integer","title":"Publish","default":0},"signal":{"type":"integer","title":"Signal","default":0},"read":{"type":"integer","title":"Read","default":0},"stream":{"type":"integer","title":"Stream","default":0},"peer_signal":{"type":"integer","title":"Peer Signal","default":0}},"type":"object","required":["day"],"title":"UsagePoint","description":"One day of one class, for one environment. `day` is a UTC calendar date."},"UsageSeries":{"properties":{"org_id":{"type":"string","title":"Org Id"},"environment":{"type":"string","title":"Environment"},"start":{"type":"string","title":"Start"},"end":{"type":"string","title":"End"},"points":{"items":{"$ref":"#/components/schemas/UsagePoint"},"type":"array","title":"Points"},"provenance":{"type":"string","enum":["measured","partial","not_recorded"],"title":"Provenance","default":"measured"}},"type":"object","required":["org_id","environment","start","end","points"],"title":"UsageSeries","description":"A dense daily series — every day in the window is present, zeros included.\n\nDENSE ON PURPOSE. A sparse series makes the client responsible for knowing which days\nare missing, and a chart drawn from one silently connects across a gap, which reads as\n\"traffic continued\" when the truth is \"we have no idea\". Filled server-side, once."},"UsageTotals":{"properties":{"publish":{"type":"integer","title":"Publish","default":0},"signal":{"type":"integer","title":"Signal","default":0},"read":{"type":"integer","title":"Read","default":0},"stream":{"type":"integer","title":"Stream","default":0},"peer_signal":{"type":"integer","title":"Peer Signal","default":0}},"type":"object","title":"UsageTotals"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"VerifyCodePayload":{"properties":{"email":{"type":"string","format":"email","title":"Email"},"code":{"type":"string","maxLength":16,"minLength":1,"title":"Code"}},"type":"object","required":["email","code"],"title":"VerifyCodePayload"}},"securitySchemes":{"HTTPBearer":{"type":"http","scheme":"bearer"}}}}