From 361a25e584e11a4e6010bd2cc6ef04ea00f39e22 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Thu, 18 Jun 2026 18:28:46 -0600 Subject: [PATCH] feat(convey): generate native-client OpenAPI 3.1 contract with staleness + breaking guards Add code-adjacent opt-in fragments for link, observer, and push, assembled by a journal-free generator into docs/openapi/convey-clients.json with 14 operations across 13 paths. The explicit three-module fragment list keeps R0 dashboard and internal routes structurally out of the contract. Define the shared {error, reason_code, detail} Error schema with reason_code enum generated from solstone/convey/reasons.py, plus per-operation x-reason-codes as the breaking-governed referenced set distinct from the staleness-governed global enum. Model the Callosum SSE baseline as {tract, event, ts, ...open} and render the non-exhaustive tract/event registry from one CALLOSUM_REGISTRY constant into both the artifact and a generated docs/CONVEY.md block, while fixing the stale SSE route/auth prose. Cover segments protocol-version oneOf(envelope|bare-array) and document callosumStream operation-level versus in-stream SSE error-frame reason-code sets, including auth_key_invalid as pre-stream-only. Wire three checks: staleness via build_openapi_contract.py --check, breaking tripwire via check_openapi_contract.py, and conformance coverage in tests/test_openapi_contract.py. check-openapi runs in install-checks/make ci, and each failure path prints its exact regenerate or re-pin command. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 12 +- docs/CONVEY.md | 32 +- docs/design/openapi-client-contract.md | 367 +++++ docs/openapi/convey-clients.json | 2035 ++++++++++++++++++++++++ scripts/build_openapi_contract.py | 100 ++ scripts/check_openapi_contract.py | 37 + solstone/apps/link/contract.py | 336 ++++ solstone/apps/observer/contract.py | 500 ++++++ solstone/convey/contract/__init__.py | 21 + solstone/convey/contract/assemble.py | 356 +++++ solstone/convey/contract/diff.py | 272 ++++ solstone/convey/contract/spec.py | 72 + solstone/convey/push_contract.py | 99 ++ tests/test_openapi_contract.py | 349 ++++ 14 files changed, 4579 insertions(+), 9 deletions(-) create mode 100644 docs/design/openapi-client-contract.md create mode 100644 docs/openapi/convey-clients.json create mode 100644 scripts/build_openapi_contract.py create mode 100644 scripts/check_openapi_contract.py create mode 100644 solstone/apps/link/contract.py create mode 100644 solstone/apps/observer/contract.py create mode 100644 solstone/convey/contract/__init__.py create mode 100644 solstone/convey/contract/assemble.py create mode 100644 solstone/convey/contract/diff.py create mode 100644 solstone/convey/contract/spec.py create mode 100644 solstone/convey/push_contract.py create mode 100644 tests/test_openapi_contract.py diff --git a/Makefile b/Makefile index d1987ff47..d346d96f9 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ export TMPDIR := /var/tmp PYTEST_BASETEMP_INIT := BASETEMP=$$(mktemp -d /var/tmp/solstone-pytest-XXXXXX); trap 'rm -rf "$$BASETEMP"' EXIT INT TERM; PYTEST_BASETEMP_FLAG := --basetemp "$$BASETEMP" -.PHONY: install uninstall test test-cov test-app test-only format format-check install-checks ci clean clean-install coverage watch versions update update-prices preflight pre-commit skills dev all sandbox sandbox-stop install-models parakeet-helper parakeet-helper-clean wheel-macos wheel-macos-clean verify verify-api update-api-baselines service-logs check-layer-hygiene check-api-conventions check-journal-io-access check-journal-io-mechanic check-call-http-only check-tools-http-only check-access-imports-clean check-convey-bind-imports-clean check-thin-base-install check-cogitate-prompts smoke-cogitate release release-test FORCE +.PHONY: install uninstall test test-cov test-app test-only format format-check install-checks ci clean clean-install coverage watch versions update update-prices preflight pre-commit skills openapi check-openapi dev all sandbox sandbox-stop install-models parakeet-helper parakeet-helper-clean wheel-macos wheel-macos-clean verify verify-api update-api-baselines service-logs check-layer-hygiene check-api-conventions check-journal-io-access check-journal-io-mechanic check-call-http-only check-tools-http-only check-access-imports-clean check-convey-bind-imports-clean check-thin-base-install check-cogitate-prompts smoke-cogitate release release-test FORCE # Default target - install package in editable mode all: install @@ -418,6 +418,9 @@ install-checks: .installed @echo "=== Checking generated skill references ===" @$(MAKE) check-skill-references @echo "" + @echo "=== Checking OpenAPI contract ===" + @$(MAKE) check-openapi + @echo "" @echo "=== Checking extras consistency ===" @$(VENV_BIN)/python scripts/check_extras_consistency.py @echo "" @@ -523,6 +526,13 @@ check-cogitate-prompts: .installed check-skill-references: .installed $(VENV_BIN)/sol skills build --check +openapi: + $(VENV_BIN)/python scripts/build_openapi_contract.py + +check-openapi: .installed + $(VENV_BIN)/python scripts/check_openapi_contract.py + $(VENV_BIN)/python scripts/build_openapi_contract.py --check + # Re-run the live four-backend integrated-façade cogitate smoke. Spawns the # archived runner (extro `vpe/workspace/archived/`) against this venv so the # real openhands-sdk Agent path is exercised end-to-end. Requires real API diff --git a/docs/CONVEY.md b/docs/CONVEY.md index 2da4daa30..648c368e9 100644 --- a/docs/CONVEY.md +++ b/docs/CONVEY.md @@ -129,11 +129,12 @@ the I pronoun, no exception class names or paths. Put those specifics in ### Observer Callosum SSE Feed Observer clients can open a server-sent events feed at -`/app/observer//callosum`. The feed is a passive view of the Callosum bus: -each `data:` frame is the same event-shaped payload the bridge saw -(`tract`, `event`, `ts`, plus event fields). Chat events appear only after the -chat append path has written its JSONL record, so subscribers see post-disk -state rather than speculative messages. +`/app/observer/callosum`. The observer key is supplied in the +`X-Solstone-Observer` header or the `Authorization: Bearer` header. The feed is +a passive view of the Callosum bus: each `data:` frame is the same event-shaped +payload the bridge saw (`tract`, `event`, `ts`, plus event fields). Chat events +appear only after the chat append path has written its JSONL record, so +subscribers see post-disk state rather than speculative messages. This endpoint is inside the observer trust boundary. It performs no redaction or per-field filtering because observers are treated as part of the local @@ -146,9 +147,24 @@ and relies on producers to keep `tract`/`event`/`ts` discipline. In a hosted or multi-tenant mode, the feed will also need scoping by the observer's authorized facet or scope set before forwarding any event. -The observer SSE feed (`/app/observer/api/list` flipping `live` on/off as a -Callosum ping flows through the bridge) is exercised by the `apps/observer` SSE -tests; a registered observer client opens the feed and the bridge emits the ping. + +| Tract | Events | +|---|---| +| `activity` | `live`, `recorded` | +| `chat` | `owner_message`, `sol_message`, `talent_spawned`, `talent_finished`, `talent_errored`, `reflection_ready`, `chat_queue_depth`, `chat_error`, `sol_chat_request`, `sol_chat_request_superseded`, `owner_chat_open`, `owner_chat_dismissed`, `support_draft`, `result`, `support_submit_claim` | +| `cortex` | `request`, `start`, `thinking`, `tool_start`, `tool_end`, `finish`, `error`, `talent_updated`, `info`, `status` | +| `importer` | `started`, `status`, `completed`, `error` | +| `logs` | `exec`, `line`, `exit` | +| `navigate` | `request` | +| `notification` | `*` | +| `observe` | `status`, `observing`, `detected`, `described`, `transcribed`, `observed` | +| `supervisor` | `started`, `stopped`, `restarting`, `status`, `queue` | +| `sync` | `status` | +| `think` | `started`, `status`, `group_started`, `group_completed`, `talent_started`, `talent_completed`, `completed`, `segments_started`, `segments_completed` | + + +The observer SSE feed is exercised by the `apps/observer` SSE tests; a +registered observer client opens the feed and the bridge emits the ping. ### Adding a New App diff --git a/docs/design/openapi-client-contract.md b/docs/design/openapi-client-contract.md new file mode 100644 index 000000000..43476df3f --- /dev/null +++ b/docs/design/openapi-client-contract.md @@ -0,0 +1,367 @@ +# Convey Native-Client OpenAPI Contract + +## 1. Fragment And Ownership Shape + +The contract framework will live under `solstone/convey/contract/`: + +| Path | Role | +|---|---| +| `solstone/convey/contract/spec.py` | Frozen dataclasses for the small contract DSL: `OperationSpec`, `RequestSpec`, `ResponseSpec`, `FieldSpec`, and `ParamSpec`. | +| `solstone/convey/contract/assemble.py` | Assembles ordered `OperationSpec` values into an OpenAPI 3.1 document, introspects `solstone.convey.reasons`, and owns `CALLOSUM_REGISTRY`. | + +The code-adjacent opt-in fragments are: + +| Path | Ownership evidence | +|---|---| +| `solstone/apps/link/contract.py` | Link routes are owned by the link app blueprint at `solstone/apps/link/routes.py:124-128`. | +| `solstone/apps/observer/contract.py` | Observer routes are owned by the observer app blueprint at `solstone/apps/observer/routes.py:79-86`. | +| `solstone/convey/push_contract.py` | Push is a root Convey blueprint beside `solstone/convey/push.py`, whose `push_bp` is defined at `solstone/convey/push.py:30`. | + +Each fragment exposes `OPERATIONS: list[OperationSpec]`. The generator uses only +this explicit fragment list: + +- `solstone.apps.link.contract` +- `solstone.apps.observer.contract` +- `solstone.convey.push_contract` + +The generator never scrapes `app.url_map`; membership is exactly what the +fragments declare. Route drift is checked in pytest, not during generation. + +`OperationSpec` should store the Flask/Werkzeug rule as the route identity +because Flask rules use `` params (`solstone/apps/observer/routes.py:1029`, +`solstone/apps/observer/routes.py:1132`). Assembly converts that rule to the +OpenAPI path form (`{day}`), with an optional explicit OpenAPI path override if +a future rule cannot be converted mechanically. + +The dataclasses are feasible if they are not over-normalized. `ResponseSpec` +and `RequestSpec` need to allow a raw schema mapping or component reference in +addition to named fields. That keeps frozen dataclasses workable for: + +- Free-form maps such as `days` and `segments`. +- Top-level array responses. +- `oneOf` for `observer.ingestSegments`. +- `text/event-stream` responses with an event payload component. + +Object schemas remain open (`additionalProperties: true`). Named fields are +still declared so conformance can reject accidental top-level response drift. + +## 2. Operation IDs + +These operation IDs are pinned and renames are breaking: + +- `link.pairStart` +- `link.pair` +- `link.unpair` +- `link.localEndpoints` +- `link.status` +- `observer.register` +- `observer.ingestUpload` +- `observer.ingestEvent` +- `observer.ingestManifest` +- `observer.ingestManifestDay` +- `observer.ingestSegments` +- `observer.callosumStream` +- `push.register` +- `push.unregister` + +They are 1:1 with the handler set in `solstone/apps/link/routes.py:401-861`, +`solstone/apps/observer/routes.py:256-1250`, and +`solstone/convey/push.py:73-112`. + +## 3. Per-Operation Table + +Paths below are OpenAPI paths. Flask conformance uses the corresponding +Werkzeug rules, including ``. + +| Method | Path | operationId | Named request fields | Named success response fields | `x-reason-codes` | +|---|---|---|---|---|---| +| POST | `/app/link/pair-start` | `link.pairStart` | body: `device_label?`, `role?` (`solstone/apps/link/routes.py:530-535`) | `nonce`, `pair_link`, `expires_in`, `rotating`, `device_label`, `ca_fingerprint` (`routes.py:287-294`, `routes.py:594-602`) | `invalid_operation_for_state`, `pairing_request_invalid`, `pl_revoked` | +| POST | `/app/link/pair` | `link.pair` | query: `token?`; body: `csr`, `nonce?`, `device_label?`, `sender_instance_id?` (`routes.py:704-724`) | `client_cert`, `ca_chain`, `instance_id`, `home_label`, `home_attestation`, `fingerprint`, `local_endpoints?` (`routes.py:622-633`) | `missing_required_field`, `operation_no_longer_available`, `pairing_key_invalid`, `pairing_request_invalid`, `pl_revoked` | +| POST | `/app/link/unpair` | `link.unpair` | body: `fingerprint?`, `device_label?`; one is required (`routes.py:797-818`) | `unpaired` (`routes.py:861`) | `missing_required_field`, `paired_device_not_found`, `pl_revoked` | +| GET | `/app/link/local-endpoints` | `link.localEndpoints` | none | `v`, `endpoints`, `ttl_s`, `generated_at` (`routes.py:507-513`, `solstone/think/link/local_endpoints.py:34-40`) | `pl_revoked` | +| GET | `/app/link/api/status` | `link.status` | none | `instance_id`, `home_label`, `enrolled`, `relay_url`, `ca_fingerprint`, `lan_accessible`, `posture`, `reachability`, `relay_state`, `home_address`, `vpn` (`routes.py:421-435`) | `pl_revoked` | +| POST | `/app/observer/register` | `observer.register` | body: `platform`, `hostname`, `stream_type`, `version`, `label?` (`solstone/apps/observer/routes.py:419-452`) | `key`, `prefix`, `name`, `ingest_url`, `protocol_version` (`routes.py:467-475`) | `invalid_segment_or_stream`, `local_request_only`, `missing_required_field`, `settings_operation_failed` | +| POST | `/app/observer/ingest` | `observer.ingestUpload` | auth: `Authorization` bearer or `X-Solstone-Observer`; multipart: `segment`, `day`, `files`, `host?`, `platform?`, `meta?` (`routes.py:890-942`) | normal/collision: `status`, `segment`, `files`, `bytes`; duplicate: `status`, `existing_segment`, `message` (`routes.py:740-747`, `routes.py:859-865`) | `auth_key_invalid`, `auth_required`, `feature_unavailable`, `ingest_no_files`, `ingest_storage_failed`, `invalid_day`, `invalid_segment_or_stream`, `missing_required_field`, `pl_revoked` | +| POST | `/app/observer/ingest/event` | `observer.ingestEvent` | auth; body: `tract`, `event`, plus open event fields (`routes.py:1073-1093`) | `status` (`routes.py:1100`) | `auth_key_invalid`, `auth_required`, `feature_unavailable`, `missing_required_field`, `pl_revoked` | +| GET | `/app/observer/ingest/manifest` | `observer.ingestManifest` | auth | `days` free-form map (`routes.py:1012-1026`) | `auth_key_invalid`, `auth_required`, `feature_unavailable`, `pl_revoked` | +| GET | `/app/observer/ingest/manifest/{day}` | `observer.ingestManifestDay` | auth; path: `day` (`routes.py:1029-1038`) | `version`, `day`, `created_at`, `host`, `segments` free-form map (`routes.py:1039-1061`) | `auth_key_invalid`, `auth_required`, `feature_unavailable`, `invalid_day`, `pl_revoked` | +| GET | `/app/observer/ingest/segments/{day}` | `observer.ingestSegments` | auth; path: `day`; query: `stream?`; header: `X-Solstone-Protocol-Version?` (`routes.py:1103-1112`, `routes.py:1132-1175`) | v2 envelope: `items`, `total`, `protocol_version`; legacy: top-level array of segment items (`routes.py:1115-1129`, `routes.py:1209-1249`) | `auth_key_invalid`, `auth_required`, `feature_unavailable`, `invalid_day`, `pl_revoked` | +| GET | `/app/observer/callosum` | `observer.callosumStream` | auth | `text/event-stream`; data payload names `tract`, `event`, `ts`, plus passthrough fields (`routes.py:240-255`, `routes.py:302-308`) | `auth_key_invalid`, `auth_required`, `feature_unavailable`, `pl_revoked` | +| POST | `/api/push/register` | `push.register` | body: `device_token`, `bundle_id`, `environment`, `platform` (`solstone/convey/push.py:78-96`) | `registered`, `device_count` (`push.py:96-103`) | `invalid_json_request`, `pl_revoked`, `push_request_invalid` | +| DELETE | `/api/push/register` | `push.unregister` | none | `removed`, `device_count` (`push.py:106-112`) | `pl_revoked`, `push_request_invalid` | + +The `pl_revoked` entries on link and push operations come from the root access +gate, not the handlers. The non-exempt set is established by +`solstone/convey/root.py:75-97`; the legacy `reason` body is emitted at +`solstone/convey/root.py:110-118`. `observer.callosumStream` is also non-exempt, +but it additionally has handler-local observer auth errors through +`resolve_observer_identity()`. + +`link.localEndpoints` also has a bare non-reason-coded Flask 404 for non-loopback +requests at `solstone/apps/link/routes.py:503-506`. That response is documented +separately from `x-reason-codes`. + +## 4. Reason-Code Two-Tier Model + +The global enum is generated from module-level `Reason` instances in +`solstone.convey.reasons`. `Reason` is a frozen dataclass with +`code`, `message`, and `status` at `solstone/convey/reasons.py:7-11`. The module +currently has 84 such instances and no `__all__`. Assembly will sort all +`.code` values into: + +- `components.schemas.Error.properties.reason_code.enum` + +The shared Error schema is: + +- `error`: string +- `reason_code`: string, enum of all global codes +- `detail`: string +- `additionalProperties: true` + +`additionalProperties: true` absorbs the access-gate legacy `reason` key from +`error_response_with_reason()` (`solstone/convey/utils.py:300-318`), while normal +handler errors use `error_response()` (`solstone/convey/utils.py:269-297`). + +The per-operation referenced set is emitted as `x-reason-codes` on each +contracted error response. Removing a referenced code is breaking. Adding a new +global code that is not referenced by any operation is staleness only. + +Provider-readiness/runtime reason registries are separate string registries: +`READINESS_REASON_CODES` and `REASON_CODES` live at +`solstone/think/providers/state.py:33-47`, runtime codes live at +`solstone/think/providers/shared.py:213-225`, and readiness presentation lives +at `solstone/convey/provider_readiness.py:83-299`. None of the 14 fragments +should import those registries. + +## 5. Segments Version Handling + +`observer.ingestSegments` has an optional integer header parameter +`X-Solstone-Protocol-Version`. The header name and current version come from +`solstone/observe/protocol.py:12-16`. + +The handler defaults absent or unparsable headers to protocol version 1 at +`solstone/apps/observer/routes.py:1103-1112`. It returns the v2 collection +envelope when `client_pv >= OBSERVER_PROTOCOL_VERSION`, currently 2, at +`solstone/apps/observer/routes.py:1124-1128`; otherwise it returns a bare array +at `solstone/apps/observer/routes.py:1129`. + +The 200 response schema is `oneOf`: + +- `SegmentsEnvelope`: object with `items`, `total`, `protocol_version`. +- `SegmentsArray`: array of `SegmentItem`. + +`SegmentItem` names `key`, `observed`, `files`, and optional `original_key`. +Each file object names `name`, `size`, `sha256`, `status`, optional +`submitted_name`, and optional `current_path` at +`solstone/apps/observer/routes.py:1209-1249`. + +This does not introduce a new negotiation system. `protocol_version` is only the +existing v2 response-envelope field. + +## 6. SSE Envelope And Registry + +`observer.callosumStream` returns a 200 `text/event-stream` response. OpenAPI +3.1 can represent this as a normal response media type, with the schema +documenting the JSON payload carried in each `data:` frame. + +`components.schemas.CallosumEvent` is: + +- required `tract`: string +- required `event`: string +- required `ts`: integer +- `additionalProperties: true` + +The response description must preserve the actual frame formats: + +- Data frame: `data: {json}\n\n` (`solstone/apps/observer/routes.py:298`) +- Heartbeat: `: heartbeat\n\n` (`routes.py:270`, `routes.py:293`) +- Error frame: `event: error\ndata: {Error}\n\n` (`routes.py:111-112`) + +The SSE response will also carry an `x-sse-error-frame` extension containing: + +- schema reference: `#/components/schemas/Error` +- `x-reason-codes`: `auth_required`, `pl_revoked`, `feature_unavailable` + +The non-exhaustive registry is emitted as `x-callosum-registry` from a single +Python constant, `CALLOSUM_REGISTRY`, in `solstone/convey/contract/assemble.py`. +The same constant is rendered into `docs/CONVEY.md` inside this marker block: + +- `` +- `` + +The registry should include the documented tracts from `docs/CALLOSUM.md:33-148`: + +- `cortex`: `request`, `start`, `thinking`, `tool_start`, `tool_end`, `finish`, `error`, `talent_updated`, `info`, `status` +- `supervisor`: `started`, `stopped`, `restarting`, `status`, `queue` +- `logs`: `exec`, `line`, `exit` +- `observe`: `status`, `observing`, `detected`, `described`, `transcribed`, `observed` +- `importer`: `started`, `status`, `completed`, `error` +- `think`: `started`, `status`, `group_started`, `group_completed`, `talent_started`, `talent_completed`, `completed`, `segments_started`, `segments_completed` +- `activity`: `live`, `recorded` +- `sync`: `status` +- `notification`: `*` +- `navigate`: `request` + +It should also include the implemented `chat` tract from +`solstone/convey/chat_stream.py:35-74` and `solstone/convey/chat_stream.py:349-366`: + +- `chat`: `owner_message`, `sol_message`, `talent_spawned`, `talent_finished`, `talent_errored`, `reflection_ready`, `chat_queue_depth`, `chat_error`, `sol_chat_request`, `sol_chat_request_superseded`, `owner_chat_open`, `owner_chat_dismissed`, `support_draft`, `result`, `support_submit_claim` + +Implementation should also fix the stale non-generated prose in +`docs/CONVEY.md`: the route is currently wrong at `docs/CONVEY.md:131-132`, and +the `api/list` sentence is stale at `docs/CONVEY.md:149-151`. + +## 7. Artifact Path And Header + +The generated artifact path is: + +- `docs/openapi/convey-clients.json` + +`docs/openapi/` is a new directory. There is no current path collision. + +The generated file is pretty-printed JSON with stable key order and a trailing +newline. Header fields: + +- `openapi`: `3.1.0` +- `info.title`: `Solstone Convey Native-Client Contract` +- `info.version`: `1.0.0` +- `info.x-generated-by`: `make openapi (scripts/build_openapi_contract.py)` +- `info.x-generated`: `true` +- `info.description`: generated-file notice, regenerate command, and do-not-hand-edit warning + +`info.version` is the static document version. It is not observer protocol +negotiation. + +## 8. Checks, Make Targets, And Messages + +There are three check surfaces. + +### Generator Staleness + +`scripts/build_openapi_contract.py` is thin. It imports assembly from +`solstone.convey.contract`, renders the OpenAPI artifact, and updates the +generated Callosum registry block in `docs/CONVEY.md`. + +`--check` mode regenerates in memory and compares: + +- `docs/openapi/convey-clients.json` +- the marker-delimited generated block in `docs/CONVEY.md` + +On diff it exits 1 and prints exactly: + +```text +OpenAPI generated outputs are stale: {paths}. Run: make openapi +``` + +### Breaking Tripwire + +`scripts/check_openapi_contract.py` regenerates the current spec, loads the +committed artifact, and classifies differences. + +Breaking: + +- removed or renamed `operationId` +- removed endpoint +- removed or renamed named response field +- removed request field +- new required request field +- removed parameter +- removed per-operation referenced reason code + +Additive and allowed: + +- new operation +- new optional field +- new optional parameter +- global reason enum addition +- registry addition + +Removal of a global reason code that is not referenced by any operation is +staleness, not breaking. + +On breaking diff it exits 1 and prints exactly: + +```text +OpenAPI contract breaking changes detected: {items}. If intentional, run `make openapi` to re-pin and notify native-client owners; otherwise revert. +``` + +### Conformance Tests + +`tests/test_openapi_contract.py` is a normal pytest module. `make test` and +`make test-cov` already collect `tests/` and `solstone/apps/` at +`Makefile:307-317`. + +The test creates a Flask app with `create_app(journal=...)` +(`solstone/convey/__init__.py:54-55`) and marks setup complete using +`tests/_baseline_harness.py:150-160`. + +Conformance checks: + +- Each fragment method/rule resolves in `app.url_map`; this catches + fragment-vs-reality drift without making the generator journal-aware. +- Observer auth works through both Bearer and `X-Solstone-Observer`, matching + `solstone/apps/observer/utils.py:296-333`. +- `observer.ingestSegments` returns the v2 envelope or legacy array based on + `X-Solstone-Protocol-Version`. +- JSON and multipart parsing are exercised for at least push/register and + observer/ingest. +- At least one malformed or unauthorized request returns structured + `{error, reason_code, detail}`. +- Each reachable named response has no undeclared top-level fields unless the + response is explicitly free-form. +- In-memory classifier tests cover the reviewer scenarios for operation + removal, named-field removal, required request-field addition, per-operation + reason-code removal, and global-enum staleness. + +On undeclared top-level response fields, the test fails with exactly: + +```text +OpenAPI contract conformance failed: {operationId} returned undeclared top-level field(s): {fields}. Declare it optional in {fragment_path}, then run make openapi, or fix the handler. +``` + +### Make Targets + +Add: + +- `make openapi`: regenerate artifact and `docs/CONVEY.md` generated block. +- `make check-openapi`: run the breaking tripwire first, then the staleness check. + +The order is intentional. If staleness ran first and exited on any diff, the +breaking classifier would never report breaking diffs. + +Wire `check-openapi` into `install-checks`, next to the existing generated +reference checks. The current pattern is `check-* : .installed` targets at +`Makefile:479-524`, `install-checks` chaining at `Makefile:383-427`, and `ci` +depending on `install-checks` at `Makefile:428-430`. + +## 9. Feasibility Findings + +Most decisions validate cleanly against the current code. + +Amendment required: `make check-openapi` must run +`scripts/check_openapi_contract.py` before `scripts/build_openapi_contract.py +--check`. The proposed order in the decision text had staleness first, but +staleness exits 1 on any diff. That would prevent breaking diffs from reaching +the breaking classifier. + +Amendment required: `OperationSpec` needs to preserve the Flask/Werkzeug rule +or an equivalent route identity in addition to the OpenAPI path. Flask rules use +`` (`solstone/apps/observer/routes.py:1029`, +`solstone/apps/observer/routes.py:1132`); OpenAPI paths use `{day}`. A small +rule-to-OpenAPI conversion in assembly is sufficient. + +Amendment required: `ResponseSpec` and `RequestSpec` need a raw schema mapping +or component-reference escape hatch. Field-only dataclasses are too rigid for +`oneOf`, top-level arrays, and `text/event-stream`, but frozen dataclasses remain +feasible with that minimal extension. + +Documentation mismatch: the assignment context says `docs/CALLOSUM.md:33-148` +contains `chat`, but that range does not. The implemented chat tract is emitted +from `solstone/convey/chat_stream.py:349-366`, with valid event names defined at +`solstone/convey/chat_stream.py:35-74`. The generated registry should include +chat from source and can become the source of truth for the CONVEY.md block. + +Contract assumption to state: non-exempt routes can redirect to setup before +reaching handlers when setup is incomplete (`solstone/convey/root.py:120-122`). +The native-client contract and conformance tests assume a setup-complete journal, +matching the baseline harness setup path. diff --git a/docs/openapi/convey-clients.json b/docs/openapi/convey-clients.json new file mode 100644 index 000000000..62dc5b858 --- /dev/null +++ b/docs/openapi/convey-clients.json @@ -0,0 +1,2035 @@ +{ + "components": { + "schemas": { + "CallosumEvent": { + "additionalProperties": true, + "properties": { + "event": { + "type": "string" + }, + "tract": { + "type": "string" + }, + "ts": { + "type": "integer" + } + }, + "required": [ + "tract", + "event", + "ts" + ], + "type": "object" + }, + "Error": { + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "error": { + "type": "string" + }, + "reason_code": { + "enum": [ + "activities_busy", + "activity_already_exists", + "activity_invalid", + "activity_not_found", + "activity_protected", + "agent_unavailable", + "auth_key_invalid", + "auth_required", + "awareness_busy", + "awareness_section_not_found", + "backup_busy", + "backup_not_confirmed", + "backup_operation_failed", + "backup_unavailable", + "chat_queue_full", + "convey_busy", + "convey_operation_failed", + "corrupt_config", + "entity_alias_conflict", + "entity_already_exists", + "entity_blocked", + "entity_busy", + "entity_not_found", + "entity_operation_failed", + "facet_already_exists", + "facet_not_found", + "feature_unavailable", + "file_not_found", + "file_read_failed", + "health_report_failed", + "identity_busy", + "import_conflict", + "import_metadata_failed", + "import_not_found", + "ingest_no_files", + "ingest_storage_failed", + "invalid_config_value", + "invalid_day", + "invalid_entity_type", + "invalid_json_request", + "invalid_month", + "invalid_operation_for_state", + "invalid_path", + "invalid_request_value", + "invalid_segment_or_stream", + "journal_source_problem", + "ledger_item_not_found", + "local_request_only", + "missing_request_body", + "missing_required_field", + "observer_restart_failed", + "operation_no_longer_available", + "paired_device_not_found", + "pairing_key_invalid", + "pairing_request_invalid", + "pl_revoked", + "principal_entity_protected", + "provider_key_missing", + "push_request_invalid", + "raw_media_not_available", + "recovery_key_mismatch", + "reprocess_already_complete", + "reprocess_past_only", + "reprocess_unreachable", + "service_busy", + "service_operation_failed", + "settings_operation_failed", + "speaker_attribution_state_invalid", + "speaker_command_failed", + "speaker_labels_busy", + "speaker_not_found", + "speaker_owner_centroid_required", + "speaker_owner_voice_too_close", + "speaker_review_unavailable", + "speaker_sentence_missing", + "speaker_voiceprint_busy", + "support_portal_failed", + "talent_not_found", + "talent_operation_failed", + "talent_run_malformed", + "talent_run_pending", + "timeline_month_not_found", + "unknown_service", + "voice_unavailable" + ], + "type": "string" + } + }, + "required": [ + "error", + "reason_code", + "detail" + ], + "type": "object" + }, + "SegmentFile": { + "additionalProperties": true, + "properties": { + "current_path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sha256": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "submitted_name": { + "type": "string" + } + }, + "required": [ + "name", + "size", + "sha256", + "status" + ], + "type": "object" + }, + "SegmentItem": { + "additionalProperties": true, + "properties": { + "files": { + "items": { + "$ref": "#/components/schemas/SegmentFile" + }, + "type": "array" + }, + "key": { + "type": "string" + }, + "observed": { + "type": "boolean" + }, + "original_key": { + "type": "string" + } + }, + "required": [ + "key", + "observed", + "files" + ], + "type": "object" + }, + "SegmentsEnvelope": { + "additionalProperties": true, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/SegmentItem" + }, + "type": "array" + }, + "protocol_version": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "items", + "total", + "protocol_version" + ], + "type": "object" + } + } + }, + "info": { + "description": "Generated native-client contract for the Convey HTTP surface. Regenerate with `make openapi`; do not hand-edit this file.", + "title": "Solstone Convey Native-Client Contract", + "version": "1.0.0", + "x-generated": true, + "x-generated-by": "make openapi (scripts/build_openapi_contract.py)" + }, + "openapi": "3.1.0", + "paths": { + "/api/push/register": { + "delete": { + "description": "Remove the push token for the current paired device fingerprint.", + "operationId": "push.unregister", + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "device_count": 0, + "removed": true + }, + "schema": { + "additionalProperties": true, + "properties": { + "device_count": { + "type": "integer" + }, + "removed": { + "type": "boolean" + } + }, + "required": [ + "removed", + "device_count" + ], + "type": "object" + } + } + }, + "description": "Push token removal result." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No paired device fingerprint was available on the connection.", + "x-reason-codes": [ + "push_request_invalid" + ] + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Access gate rejected a revoked paired-link identity.", + "x-reason-codes": [ + "pl_revoked" + ] + } + }, + "summary": "Unregister push device", + "tags": [ + "push" + ] + }, + "post": { + "description": "Register or replace an iOS push token for the paired device fingerprint on the current connection.", + "operationId": "push.register", + "requestBody": { + "content": { + "application/json": { + "example": { + "bundle_id": "org.solpbc.solstone-swift", + "device_token": "abcdef0123456789", + "environment": "development", + "platform": "ios" + }, + "schema": { + "additionalProperties": true, + "properties": { + "bundle_id": { + "type": "string" + }, + "device_token": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "platform": { + "type": "string" + } + }, + "required": [ + "device_token", + "bundle_id", + "environment", + "platform" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "device_count": 1, + "registered": true + }, + "schema": { + "additionalProperties": true, + "properties": { + "device_count": { + "type": "integer" + }, + "registered": { + "type": "boolean" + } + }, + "required": [ + "registered", + "device_count" + ], + "type": "object" + } + } + }, + "description": "Push token registered." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Push registration request failed validation.", + "x-reason-codes": [ + "invalid_json_request", + "push_request_invalid" + ] + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Access gate rejected a revoked paired-link identity.", + "x-reason-codes": [ + "pl_revoked" + ] + } + }, + "summary": "Register push device", + "tags": [ + "push" + ] + } + }, + "/app/link/api/status": { + "get": { + "description": "Return the current link service posture and reachability view.", + "operationId": "link.status", + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "ca_fingerprint": "9c5f2e0c8e6a42f0a32e55e5cf7f5b4a", + "enrolled": true, + "home_address": null, + "home_label": "home", + "instance_id": "4d1f3d57-4f39-4930-b8f8-5e6f2a84d51a", + "lan_accessible": true, + "posture": "lan", + "reachability": "local", + "relay_state": "not_configured", + "relay_url": "https://relay.solstone.local", + "vpn": { + "active": null, + "candidates": [] + } + }, + "schema": { + "additionalProperties": true, + "properties": { + "ca_fingerprint": { + "type": [ + "string", + "null" + ] + }, + "enrolled": { + "type": "boolean" + }, + "home_address": { + "type": [ + "string", + "null" + ] + }, + "home_label": { + "type": [ + "string", + "null" + ] + }, + "instance_id": { + "type": [ + "string", + "null" + ] + }, + "lan_accessible": { + "type": "boolean" + }, + "posture": { + "type": "string" + }, + "reachability": { + "type": "string" + }, + "relay_state": { + "type": "string" + }, + "relay_url": { + "type": "string" + }, + "vpn": { + "additionalProperties": true, + "properties": { + "active": { + "type": [ + "string", + "null" + ] + }, + "candidates": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + }, + "required": [ + "instance_id", + "home_label", + "enrolled", + "relay_url", + "ca_fingerprint", + "lan_accessible", + "posture", + "reachability", + "relay_state", + "home_address", + "vpn" + ], + "type": "object" + } + } + }, + "description": "Link status snapshot." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Access gate rejected a revoked paired-link identity.", + "x-reason-codes": [ + "pl_revoked" + ] + } + }, + "summary": "Read link status", + "tags": [ + "link" + ] + } + }, + "/app/link/local-endpoints": { + "get": { + "description": "Return loopback-only LAN endpoint hints for link clients.", + "operationId": "link.localEndpoints", + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "endpoints": [ + { + "ip": "192.168.1.10", + "port": 7657, + "scope": "lan" + } + ], + "generated_at": "2026-06-18T12:00:00Z", + "ttl_s": 3600, + "v": 1 + }, + "schema": { + "additionalProperties": true, + "properties": { + "endpoints": { + "items": { + "additionalProperties": true, + "properties": { + "ip": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "scope": { + "type": "string" + } + }, + "required": [ + "ip", + "port", + "scope" + ], + "type": "object" + }, + "type": "array" + }, + "generated_at": { + "type": "string" + }, + "ttl_s": { + "type": "integer" + }, + "v": { + "type": "integer" + } + }, + "required": [ + "v", + "endpoints", + "ttl_s", + "generated_at" + ], + "type": "object" + } + } + }, + "description": "Current local endpoint advertisement." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Access gate rejected a revoked paired-link identity.", + "x-reason-codes": [ + "pl_revoked" + ] + }, + "404": { + "description": "Non-loopback request; bare Flask abort, no reason body." + } + }, + "summary": "List local link endpoints", + "tags": [ + "link" + ] + } + }, + "/app/link/pair": { + "post": { + "description": "Accept a client CSR plus nonce, then return a signed certificate and home attestation.", + "operationId": "link.pair", + "parameters": [ + { + "description": "Pairing nonce; the body nonce can be used instead.", + "in": "query", + "name": "token", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "example": { + "csr": "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----\n", + "device_label": "Jer iPhone", + "nonce": "5f0d8c8b9f1e48b0a5f80b98f3d5e9b0", + "sender_instance_id": "ios-01" + }, + "schema": { + "additionalProperties": true, + "properties": { + "csr": { + "type": "string" + }, + "device_label": { + "type": "string" + }, + "nonce": { + "type": "string" + }, + "sender_instance_id": { + "type": "string" + } + }, + "required": [ + "csr" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "ca_chain": [ + "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n" + ], + "client_cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n", + "fingerprint": "sha256:abc123", + "home_attestation": "eyJhbGciOi...", + "home_label": "home", + "instance_id": "4d1f3d57-4f39-4930-b8f8-5e6f2a84d51a", + "local_endpoints": [ + { + "ip": "192.168.1.10", + "port": 7657, + "scope": "lan" + } + ] + }, + "schema": { + "additionalProperties": true, + "properties": { + "ca_chain": { + "items": { + "type": "string" + }, + "type": "array" + }, + "client_cert": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "home_attestation": { + "type": "string" + }, + "home_label": { + "type": "string" + }, + "instance_id": { + "type": "string" + }, + "local_endpoints": { + "items": { + "additionalProperties": true, + "properties": { + "ip": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "scope": { + "type": "string" + } + }, + "required": [ + "ip", + "port", + "scope" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "client_cert", + "ca_chain", + "instance_id", + "home_label", + "home_attestation", + "fingerprint" + ], + "type": "object" + } + } + }, + "description": "Signed link material for the paired client." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Pair request rejected by handler validation.", + "x-reason-codes": [ + "missing_required_field", + "pairing_key_invalid", + "pairing_request_invalid" + ] + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Access gate rejected a revoked paired-link identity.", + "x-reason-codes": [ + "pl_revoked" + ] + }, + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Nonce expired or was already used.", + "x-reason-codes": [ + "operation_no_longer_available" + ] + } + }, + "summary": "Complete link pairing", + "tags": [ + "link" + ] + } + }, + "/app/link/pair-start": { + "post": { + "description": "Create a short-lived pairing nonce and return the link payload a native client can scan or open.", + "operationId": "link.pairStart", + "requestBody": { + "content": { + "application/json": { + "example": { + "device_label": "Jer iPhone", + "role": "phone" + }, + "schema": { + "additionalProperties": true, + "properties": { + "device_label": { + "type": "string" + }, + "role": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "ca_fingerprint": "9c5f2e0c8e6a42f0a32e55e5cf7f5b4a", + "device_label": "Jer iPhone", + "expires_in": 300, + "nonce": "5f0d8c8b9f1e48b0a5f80b98f3d5e9b0", + "pair_link": "https://solstone.link/pair#0ABCD...", + "rotating": false + }, + "schema": { + "additionalProperties": true, + "properties": { + "ca_fingerprint": { + "type": "string" + }, + "device_label": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "nonce": { + "type": "string" + }, + "pair_link": { + "type": "string" + }, + "rotating": { + "type": "boolean" + } + }, + "required": [ + "nonce", + "pair_link", + "expires_in", + "rotating", + "device_label", + "ca_fingerprint" + ], + "type": "object" + } + } + }, + "description": "Pairing nonce and link payload." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Pair-start request rejected by handler validation.", + "x-reason-codes": [ + "invalid_operation_for_state", + "pairing_request_invalid" + ] + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Access gate rejected a revoked paired-link identity.", + "x-reason-codes": [ + "pl_revoked" + ] + } + }, + "summary": "Start link pairing", + "tags": [ + "link" + ] + } + }, + "/app/link/unpair": { + "post": { + "description": "Remove a paired client by fingerprint or device label.", + "operationId": "link.unpair", + "requestBody": { + "content": { + "application/json": { + "example": { + "fingerprint": "sha256:abc123" + }, + "schema": { + "additionalProperties": true, + "properties": { + "device_label": { + "type": "string" + }, + "fingerprint": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "unpaired": "sha256:abc123" + }, + "schema": { + "additionalProperties": true, + "properties": { + "unpaired": { + "type": "string" + } + }, + "required": [ + "unpaired" + ], + "type": "object" + } + } + }, + "description": "The revoked fingerprint." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Neither fingerprint nor device label was supplied.", + "x-reason-codes": [ + "missing_required_field" + ] + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Access gate rejected a revoked paired-link identity.", + "x-reason-codes": [ + "pl_revoked" + ] + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No paired device matched the request.", + "x-reason-codes": [ + "paired_device_not_found" + ] + } + }, + "summary": "Unpair a device", + "tags": [ + "link" + ] + } + }, + "/app/observer/callosum": { + "get": { + "description": "Open an observer-authenticated Server-Sent Events feed. Frames: data `data: {json}\\n\\n`; heartbeat `: heartbeat\\n\\n`; error `event: error\\ndata: {Error}\\n\\n`.", + "operationId": "observer.callosumStream", + "parameters": [ + { + "description": "Observer handle. Preferred over Authorization when present.", + "in": "header", + "name": "X-Solstone-Observer", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Bearer observer handle.", + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/event-stream": { + "example": { + "day": "20260618", + "event": "observing", + "segment": "143022_300", + "tract": "observe", + "ts": 1781803200000 + }, + "schema": { + "$ref": "#/components/schemas/CallosumEvent" + } + } + }, + "description": "Callosum event stream. Data frames carry CallosumEvent JSON; heartbeat frames are comments; error frames carry Error JSON.", + "x-sse-error-frame": { + "description": "In-stream `event: error` frames emit only these. auth_key_invalid is excluded here because key validity is established at stream open (routes.py:259-261); mid-stream re-checks only cover observer missing/revoked/disabled (routes.py:281-292).", + "schema": { + "$ref": "#/components/schemas/Error" + }, + "x-reason-codes": [ + "auth_required", + "feature_unavailable", + "pl_revoked" + ] + } + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Stream-open observer auth failure; codes include 401 and 403 outcomes.", + "x-reason-codes": [ + "auth_key_invalid", + "auth_required", + "feature_unavailable", + "pl_revoked" + ] + } + }, + "summary": "Stream Callosum events", + "tags": [ + "observer" + ] + } + }, + "/app/observer/ingest": { + "post": { + "description": "Upload one capture segment as multipart form data and trigger local observe processing.", + "operationId": "observer.ingestUpload", + "parameters": [ + { + "description": "Observer handle. Preferred over Authorization when present.", + "in": "header", + "name": "X-Solstone-Observer", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Bearer observer handle.", + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "example": { + "day": "20260618", + "files": [ + "screen.png", + "audio.flac" + ], + "host": "archon", + "meta": "{\"facet\":\"work\"}", + "platform": "linux", + "segment": "143022_300" + }, + "schema": { + "additionalProperties": true, + "properties": { + "day": { + "type": "string" + }, + "files": { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + }, + "host": { + "type": "string" + }, + "meta": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "segment": { + "type": "string" + } + }, + "required": [ + "segment", + "day", + "files" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "duplicate": { + "summary": "Duplicate segment", + "value": { + "existing_segment": "143022_300", + "message": "All files already received", + "status": "duplicate" + } + }, + "normal": { + "summary": "New segment accepted", + "value": { + "bytes": 524288, + "files": [ + "screen.png", + "audio.flac" + ], + "segment": "143022_300", + "status": "ok" + } + } + }, + "schema": { + "additionalProperties": true, + "properties": { + "bytes": { + "type": "integer" + }, + "existing_segment": { + "type": "string" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + }, + "segment": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } + } + }, + "description": "Upload accepted, collision-adjusted, or duplicate." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Upload request failed validation.", + "x-reason-codes": [ + "ingest_no_files", + "invalid_day", + "invalid_segment_or_stream", + "missing_required_field" + ] + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Observer handle missing or invalid.", + "x-reason-codes": [ + "auth_key_invalid", + "auth_required" + ] + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Observer is disabled or revoked.", + "x-reason-codes": [ + "feature_unavailable", + "pl_revoked" + ] + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Uploaded file could not be stored.", + "x-reason-codes": [ + "ingest_storage_failed" + ] + }, + "507": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No segment slot was available after collision handling.", + "x-reason-codes": [ + "ingest_storage_failed" + ] + } + }, + "summary": "Upload observer segment files", + "tags": [ + "observer" + ] + } + }, + "/app/observer/ingest/event": { + "post": { + "description": "Relay an observer-originated event onto the local Callosum bus.", + "operationId": "observer.ingestEvent", + "parameters": [ + { + "description": "Observer handle. Preferred over Authorization when present.", + "in": "header", + "name": "X-Solstone-Observer", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Bearer observer handle.", + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "example": { + "event": "status", + "state": "recording", + "tract": "observe" + }, + "schema": { + "additionalProperties": true, + "properties": { + "event": { + "type": "string" + }, + "tract": { + "type": "string" + } + }, + "required": [ + "tract", + "event" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "status": "ok" + }, + "schema": { + "additionalProperties": true, + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } + } + }, + "description": "Event relayed." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Tract or event was missing.", + "x-reason-codes": [ + "missing_required_field" + ] + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Observer handle missing or invalid.", + "x-reason-codes": [ + "auth_key_invalid", + "auth_required" + ] + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Observer is disabled or revoked.", + "x-reason-codes": [ + "feature_unavailable", + "pl_revoked" + ] + } + }, + "summary": "Relay observer event", + "tags": [ + "observer" + ] + } + }, + "/app/observer/ingest/manifest": { + "get": { + "description": "Return days with uploaded observer segment history.", + "operationId": "observer.ingestManifest", + "parameters": [ + { + "description": "Observer handle. Preferred over Authorization when present.", + "in": "header", + "name": "X-Solstone-Observer", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Bearer observer handle.", + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "days": { + "20260618": { + "segments": 2 + } + } + }, + "schema": { + "additionalProperties": true, + "properties": { + "days": { + "additionalProperties": { + "additionalProperties": true, + "properties": { + "segments": { + "type": "integer" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "required": [ + "days" + ], + "type": "object" + } + } + }, + "description": "Available manifest days keyed by day." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Observer handle missing or invalid.", + "x-reason-codes": [ + "auth_key_invalid", + "auth_required" + ] + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Observer is disabled or revoked.", + "x-reason-codes": [ + "feature_unavailable", + "pl_revoked" + ] + } + }, + "summary": "List observer manifest days", + "tags": [ + "observer" + ] + } + }, + "/app/observer/ingest/manifest/{day}": { + "get": { + "description": "Return file hashes and sizes for every segment on one day.", + "operationId": "observer.ingestManifestDay", + "parameters": [ + { + "description": "Observer handle. Preferred over Authorization when present.", + "in": "header", + "name": "X-Solstone-Observer", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Bearer observer handle.", + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "YYYYMMDD day.", + "in": "path", + "name": "day", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "created_at": 1781803200000, + "day": "20260618", + "host": "archon", + "segments": { + "archon/143022_300": { + "files": [ + { + "name": "screen.png", + "sha256": "5f70bf18a086007016bb522ec180fd0b", + "size": 2048 + } + ] + } + }, + "version": 1 + }, + "schema": { + "additionalProperties": true, + "properties": { + "created_at": { + "type": "integer" + }, + "day": { + "type": "string" + }, + "host": { + "type": "string" + }, + "segments": { + "additionalProperties": { + "additionalProperties": true, + "properties": { + "files": { + "items": { + "additionalProperties": true, + "properties": { + "name": { + "type": "string" + }, + "sha256": { + "type": "string" + }, + "size": { + "type": "integer" + } + }, + "required": [ + "name", + "sha256", + "size" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "files" + ], + "type": "object" + }, + "type": "object" + }, + "version": { + "type": "integer" + } + }, + "required": [ + "version", + "day", + "created_at", + "host", + "segments" + ], + "type": "object" + } + } + }, + "description": "Day transfer manifest." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Day path parameter was invalid.", + "x-reason-codes": [ + "invalid_day" + ] + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Observer handle missing or invalid.", + "x-reason-codes": [ + "auth_key_invalid", + "auth_required" + ] + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Observer is disabled or revoked.", + "x-reason-codes": [ + "feature_unavailable", + "pl_revoked" + ] + } + }, + "summary": "Read transfer manifest for a day", + "tags": [ + "observer" + ] + } + }, + "/app/observer/ingest/segments/{day}": { + "get": { + "description": "Return segment upload history for one day. Protocol version 2 and newer receive a collection envelope; older or absent protocol headers receive a legacy bare array.", + "operationId": "observer.ingestSegments", + "parameters": [ + { + "description": "Observer handle. Preferred over Authorization when present.", + "in": "header", + "name": "X-Solstone-Observer", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Bearer observer handle.", + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "YYYYMMDD day.", + "in": "path", + "name": "day", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Fallback stream for legacy observer history rows.", + "in": "query", + "name": "stream", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": ">= current protocol version returns the collection envelope; lower or absent returns the legacy bare array.", + "in": "header", + "name": "X-Solstone-Protocol-Version", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "legacy": { + "summary": "Legacy bare array", + "value": [ + { + "files": [ + { + "name": "screen.png", + "sha256": "5f70bf18a086007016bb522ec180fd0b", + "size": 2048, + "status": "present" + } + ], + "key": "143022_300", + "observed": true + } + ] + }, + "v2": { + "summary": "Protocol v2 envelope", + "value": { + "items": [ + { + "files": [ + { + "name": "screen.png", + "sha256": "5f70bf18a086007016bb522ec180fd0b", + "size": 2048, + "status": "present" + } + ], + "key": "143022_300", + "observed": true + } + ], + "protocol_version": 2, + "total": 1 + } + } + }, + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/SegmentsEnvelope" + }, + { + "items": { + "$ref": "#/components/schemas/SegmentItem" + }, + "type": "array" + } + ] + } + } + }, + "description": "Segment verification response." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Day path parameter was invalid.", + "x-reason-codes": [ + "invalid_day" + ] + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Observer handle missing or invalid.", + "x-reason-codes": [ + "auth_key_invalid", + "auth_required" + ] + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Observer is disabled or revoked.", + "x-reason-codes": [ + "feature_unavailable", + "pl_revoked" + ] + } + }, + "summary": "List uploaded observer segments", + "tags": [ + "observer" + ] + } + }, + "/app/observer/register": { + "post": { + "description": "Register a trusted local or paired-link observer and lock its stream identity.", + "operationId": "observer.register", + "requestBody": { + "content": { + "application/json": { + "example": { + "hostname": "archon", + "label": "Archon desktop", + "platform": "linux", + "stream_type": "desktop", + "version": "1.4.0" + }, + "schema": { + "additionalProperties": true, + "properties": { + "hostname": { + "type": "string" + }, + "label": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "stream_type": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "platform", + "hostname", + "stream_type", + "version" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "ingest_url": "/app/observer/ingest", + "key": "x7J7k2observerHandle", + "name": "archon", + "prefix": "x7J7k2ob", + "protocol_version": 2 + }, + "schema": { + "additionalProperties": true, + "properties": { + "ingest_url": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "protocol_version": { + "type": "integer" + } + }, + "required": [ + "key", + "prefix", + "name", + "ingest_url", + "protocol_version" + ], + "type": "object" + } + } + }, + "description": "Observer registration descriptor." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Register request failed validation.", + "x-reason-codes": [ + "invalid_segment_or_stream", + "missing_required_field" + ] + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Register caller was not trusted localhost or paired link.", + "x-reason-codes": [ + "local_request_only" + ] + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Observer record could not be saved.", + "x-reason-codes": [ + "settings_operation_failed" + ] + } + }, + "summary": "Register observer", + "tags": [ + "observer" + ] + } + } + }, + "x-callosum-registry": { + "activity": [ + "live", + "recorded" + ], + "chat": [ + "owner_message", + "sol_message", + "talent_spawned", + "talent_finished", + "talent_errored", + "reflection_ready", + "chat_queue_depth", + "chat_error", + "sol_chat_request", + "sol_chat_request_superseded", + "owner_chat_open", + "owner_chat_dismissed", + "support_draft", + "result", + "support_submit_claim" + ], + "cortex": [ + "request", + "start", + "thinking", + "tool_start", + "tool_end", + "finish", + "error", + "talent_updated", + "info", + "status" + ], + "importer": [ + "started", + "status", + "completed", + "error" + ], + "logs": [ + "exec", + "line", + "exit" + ], + "navigate": [ + "request" + ], + "notification": [ + "*" + ], + "observe": [ + "status", + "observing", + "detected", + "described", + "transcribed", + "observed" + ], + "supervisor": [ + "started", + "stopped", + "restarting", + "status", + "queue" + ], + "sync": [ + "status" + ], + "think": [ + "started", + "status", + "group_started", + "group_completed", + "talent_started", + "talent_completed", + "completed", + "segments_started", + "segments_completed" + ] + } +} diff --git a/scripts/build_openapi_contract.py b/scripts/build_openapi_contract.py new file mode 100644 index 000000000..e36389d26 --- /dev/null +++ b/scripts/build_openapi_contract.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Build the generated Convey native-client OpenAPI contract.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from solstone.convey.contract.assemble import CALLOSUM_REGISTRY, build_document + +ROOT = Path(__file__).resolve().parent.parent +ARTIFACT_PATH = ROOT / "docs" / "openapi" / "convey-clients.json" +CONVEY_DOC_PATH = ROOT / "docs" / "CONVEY.md" + +BEGIN_MARKER = "" +END_MARKER = "" + + +def render_openapi_json() -> str: + return json.dumps(build_document(), indent=2, sort_keys=True) + "\n" + + +def render_callosum_registry_block() -> str: + lines = [ + "| Tract | Events |", + "|---|---|", + ] + for tract in sorted(CALLOSUM_REGISTRY): + events = ", ".join(f"`{event}`" for event in CALLOSUM_REGISTRY[tract]) + lines.append(f"| `{tract}` | {events} |") + return "\n".join(lines) + + +def render_convey_doc(current: str) -> str: + try: + before, rest = current.split(BEGIN_MARKER, 1) + _old, after = rest.split(END_MARKER, 1) + except ValueError as exc: + raise ValueError("docs/CONVEY.md is missing Callosum registry markers") from exc + + block = render_callosum_registry_block() + return f"{before}{BEGIN_MARKER}\n{block}\n{END_MARKER}{after}" + + +def write_outputs() -> None: + ARTIFACT_PATH.parent.mkdir(parents=True, exist_ok=True) + ARTIFACT_PATH.write_text(render_openapi_json(), encoding="utf-8") + current_doc = CONVEY_DOC_PATH.read_text(encoding="utf-8") + CONVEY_DOC_PATH.write_text(render_convey_doc(current_doc), encoding="utf-8") + print(f"wrote {ARTIFACT_PATH.relative_to(ROOT)}") + print(f"updated {CONVEY_DOC_PATH.relative_to(ROOT)}") + + +def check_outputs() -> int: + stale: list[str] = [] + expected_artifact = render_openapi_json() + try: + current_artifact = ARTIFACT_PATH.read_text(encoding="utf-8") + except FileNotFoundError: + current_artifact = "" + if current_artifact != expected_artifact: + stale.append(str(ARTIFACT_PATH.relative_to(ROOT))) + + current_doc = CONVEY_DOC_PATH.read_text(encoding="utf-8") + expected_doc = render_convey_doc(current_doc) + if current_doc != expected_doc: + stale.append(str(CONVEY_DOC_PATH.relative_to(ROOT))) + + if stale: + paths = ", ".join(stale) + print( + f"OpenAPI generated outputs are stale: {paths}. Run: make openapi", + file=sys.stderr, + ) + return 1 + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Check generated outputs without writing files.", + ) + args = parser.parse_args() + + if args.check: + return check_outputs() + write_outputs() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_openapi_contract.py b/scripts/check_openapi_contract.py new file mode 100644 index 000000000..11feb44ec --- /dev/null +++ b/scripts/check_openapi_contract.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Detect breaking changes in the generated native-client OpenAPI contract.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from solstone.convey.contract.assemble import build_document +from solstone.convey.contract.diff import classify_changes + +ARTIFACT_PATH = Path("docs/openapi/convey-clients.json") + + +def main() -> int: + current = build_document() + committed = json.loads(ARTIFACT_PATH.read_text(encoding="utf-8")) + breaking = classify_changes(current, committed) + if not breaking: + return 0 + + for item in breaking: + print(item, file=sys.stderr) + print( + "OpenAPI contract breaking changes detected: " + f"{len(breaking)} item(s) above. If intentional, run `make openapi` " + "to re-pin and notify native-client owners; otherwise revert.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/solstone/apps/link/contract.py b/solstone/apps/link/contract.py new file mode 100644 index 000000000..01ac4c94a --- /dev/null +++ b/solstone/apps/link/contract.py @@ -0,0 +1,336 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""OpenAPI fragment for the native-client link routes.""" + +from __future__ import annotations + +from solstone.convey.contract import ( + FieldSpec, + OperationSpec, + ParamSpec, + RequestSpec, + ResponseSpec, +) + + +def _json_error( + status: int, + reason_codes: tuple[str, ...], + description: str, +) -> ResponseSpec: + return ResponseSpec( + status=status, + description=description, + reason_codes=reason_codes, + ) + + +_LOCAL_ENDPOINT_SCHEMA = { + "type": "array", + "items": { + "type": "object", + "additionalProperties": True, + "properties": { + "ip": {"type": "string"}, + "port": {"type": "integer"}, + "scope": {"type": "string"}, + }, + "required": ["ip", "port", "scope"], + }, +} + +OPERATIONS: list[OperationSpec] = [ + OperationSpec( + operation_id="link.pairStart", + method="POST", + rule="/app/link/pair-start", + summary="Start link pairing", + description=( + "Create a short-lived pairing nonce and return the link payload a " + "native client can scan or open." + ), + request=RequestSpec( + fields=( + FieldSpec("device_label", "string"), + FieldSpec("role", "string"), + ), + example={"device_label": "Jer iPhone", "role": "phone"}, + ), + responses=( + ResponseSpec( + status=200, + description="Pairing nonce and link payload.", + named_fields=( + FieldSpec("nonce", "string", required=True), + FieldSpec("pair_link", "string", required=True), + FieldSpec("expires_in", "integer", required=True), + FieldSpec("rotating", "boolean", required=True), + FieldSpec("device_label", "string", required=True), + FieldSpec("ca_fingerprint", "string", required=True), + ), + example={ + "nonce": "5f0d8c8b9f1e48b0a5f80b98f3d5e9b0", + "pair_link": "https://solstone.link/pair#0ABCD...", + "expires_in": 300, + "rotating": False, + "device_label": "Jer iPhone", + "ca_fingerprint": "9c5f2e0c8e6a42f0a32e55e5cf7f5b4a", + }, + ), + _json_error( + 400, + ("invalid_operation_for_state", "pairing_request_invalid"), + "Pair-start request rejected by handler validation.", + ), + _json_error( + 403, + ("pl_revoked",), + "Access gate rejected a revoked paired-link identity.", + ), + ), + ), + OperationSpec( + operation_id="link.pair", + method="POST", + rule="/app/link/pair", + summary="Complete link pairing", + description=( + "Accept a client CSR plus nonce, then return a signed certificate " + "and home attestation." + ), + parameters=( + ParamSpec( + "token", + "query", + required=False, + description="Pairing nonce; the body nonce can be used instead.", + ), + ), + request=RequestSpec( + fields=( + FieldSpec("csr", "string", required=True), + FieldSpec("nonce", "string"), + FieldSpec("device_label", "string"), + FieldSpec("sender_instance_id", "string"), + ), + example={ + "csr": "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----\n", + "nonce": "5f0d8c8b9f1e48b0a5f80b98f3d5e9b0", + "device_label": "Jer iPhone", + "sender_instance_id": "ios-01", + }, + ), + responses=( + ResponseSpec( + status=200, + description="Signed link material for the paired client.", + named_fields=( + FieldSpec("client_cert", "string", required=True), + FieldSpec( + "ca_chain", + "array", + required=True, + item_type="string", + ), + FieldSpec("instance_id", "string", required=True), + FieldSpec("home_label", "string", required=True), + FieldSpec("home_attestation", "string", required=True), + FieldSpec("fingerprint", "string", required=True), + FieldSpec( + "local_endpoints", "array", raw_schema=_LOCAL_ENDPOINT_SCHEMA + ), + ), + example={ + "client_cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n", + "ca_chain": [ + "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n" + ], + "instance_id": "4d1f3d57-4f39-4930-b8f8-5e6f2a84d51a", + "home_label": "home", + "home_attestation": "eyJhbGciOi...", + "fingerprint": "sha256:abc123", + "local_endpoints": [ + {"ip": "192.168.1.10", "port": 7657, "scope": "lan"} + ], + }, + ), + _json_error( + 400, + ( + "missing_required_field", + "pairing_key_invalid", + "pairing_request_invalid", + ), + "Pair request rejected by handler validation.", + ), + _json_error( + 403, + ("pl_revoked",), + "Access gate rejected a revoked paired-link identity.", + ), + _json_error( + 410, + ("operation_no_longer_available",), + "Nonce expired or was already used.", + ), + ), + ), + OperationSpec( + operation_id="link.unpair", + method="POST", + rule="/app/link/unpair", + summary="Unpair a device", + description="Remove a paired client by fingerprint or device label.", + request=RequestSpec( + fields=( + FieldSpec("fingerprint", "string"), + FieldSpec("device_label", "string"), + ), + example={"fingerprint": "sha256:abc123"}, + ), + responses=( + ResponseSpec( + status=200, + description="The revoked fingerprint.", + named_fields=(FieldSpec("unpaired", "string", required=True),), + example={"unpaired": "sha256:abc123"}, + ), + _json_error( + 400, + ("missing_required_field",), + "Neither fingerprint nor device label was supplied.", + ), + _json_error( + 403, + ("pl_revoked",), + "Access gate rejected a revoked paired-link identity.", + ), + _json_error( + 404, + ("paired_device_not_found",), + "No paired device matched the request.", + ), + ), + ), + OperationSpec( + operation_id="link.localEndpoints", + method="GET", + rule="/app/link/local-endpoints", + summary="List local link endpoints", + description="Return loopback-only LAN endpoint hints for link clients.", + responses=( + ResponseSpec( + status=200, + description="Current local endpoint advertisement.", + named_fields=( + FieldSpec("v", "integer", required=True), + FieldSpec( + "endpoints", + "array", + required=True, + raw_schema=_LOCAL_ENDPOINT_SCHEMA, + ), + FieldSpec("ttl_s", "integer", required=True), + FieldSpec("generated_at", "string", required=True), + ), + example={ + "v": 1, + "endpoints": [{"ip": "192.168.1.10", "port": 7657, "scope": "lan"}], + "ttl_s": 3600, + "generated_at": "2026-06-18T12:00:00Z", + }, + ), + _json_error( + 403, + ("pl_revoked",), + "Access gate rejected a revoked paired-link identity.", + ), + ResponseSpec( + status=404, + description="Non-loopback request; bare Flask abort, no reason body.", + ), + ), + ), + OperationSpec( + operation_id="link.status", + method="GET", + rule="/app/link/api/status", + summary="Read link status", + description="Return the current link service posture and reachability view.", + responses=( + ResponseSpec( + status=200, + description="Link status snapshot.", + named_fields=( + FieldSpec( + "instance_id", + "string", + required=True, + raw_schema={"type": ["string", "null"]}, + ), + FieldSpec( + "home_label", + "string", + required=True, + raw_schema={"type": ["string", "null"]}, + ), + FieldSpec("enrolled", "boolean", required=True), + FieldSpec("relay_url", "string", required=True), + FieldSpec( + "ca_fingerprint", + "string", + required=True, + raw_schema={"type": ["string", "null"]}, + ), + FieldSpec("lan_accessible", "boolean", required=True), + FieldSpec("posture", "string", required=True), + FieldSpec("reachability", "string", required=True), + FieldSpec("relay_state", "string", required=True), + FieldSpec( + "home_address", + "string", + required=True, + raw_schema={"type": ["string", "null"]}, + ), + FieldSpec( + "vpn", + "object", + required=True, + raw_schema={ + "type": "object", + "additionalProperties": True, + "properties": { + "active": {"type": ["string", "null"]}, + "candidates": { + "type": "array", + "items": {"type": "object"}, + }, + }, + }, + ), + ), + example={ + "instance_id": "4d1f3d57-4f39-4930-b8f8-5e6f2a84d51a", + "home_label": "home", + "enrolled": True, + "relay_url": "https://relay.solstone.local", + "ca_fingerprint": "9c5f2e0c8e6a42f0a32e55e5cf7f5b4a", + "lan_accessible": True, + "posture": "lan", + "reachability": "local", + "relay_state": "not_configured", + "home_address": None, + "vpn": {"active": None, "candidates": []}, + }, + ), + _json_error( + 403, + ("pl_revoked",), + "Access gate rejected a revoked paired-link identity.", + ), + ), + ), +] + +__all__ = ["OPERATIONS"] diff --git a/solstone/apps/observer/contract.py b/solstone/apps/observer/contract.py new file mode 100644 index 000000000..606966766 --- /dev/null +++ b/solstone/apps/observer/contract.py @@ -0,0 +1,500 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""OpenAPI fragment for the native-client observer routes.""" + +from __future__ import annotations + +from solstone.convey.contract import ( + FieldSpec, + OperationSpec, + ParamSpec, + RequestSpec, + ResponseSpec, +) + +_OBSERVER_AUTH_PARAMS = ( + ParamSpec( + "X-Solstone-Observer", + "header", + required=False, + description="Observer handle. Preferred over Authorization when present.", + ), + ParamSpec( + "Authorization", + "header", + required=False, + description="Bearer observer handle.", + ), +) + + +def _json_error( + status: int, + reason_codes: tuple[str, ...], + description: str, +) -> ResponseSpec: + return ResponseSpec( + status=status, + description=description, + reason_codes=reason_codes, + ) + + +def _observer_auth_errors() -> tuple[ResponseSpec, ResponseSpec]: + return ( + _json_error( + 401, + ("auth_key_invalid", "auth_required"), + "Observer handle missing or invalid.", + ), + _json_error( + 403, + ("feature_unavailable", "pl_revoked"), + "Observer is disabled or revoked.", + ), + ) + + +_DAY_SEGMENT_COUNT_MAP = { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": True, + "properties": {"segments": {"type": "integer"}}, + }, +} + +_MANIFEST_SEGMENT_MAP = { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": True, + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": True, + "properties": { + "name": {"type": "string"}, + "sha256": {"type": "string"}, + "size": {"type": "integer"}, + }, + "required": ["name", "sha256", "size"], + }, + } + }, + "required": ["files"], + }, +} + +_SEGMENTS_RESPONSE_SCHEMA = { + "oneOf": [ + {"$ref": "#/components/schemas/SegmentsEnvelope"}, + { + "type": "array", + "items": {"$ref": "#/components/schemas/SegmentItem"}, + }, + ] +} + +_SEGMENT_ITEM_EXAMPLE = { + "key": "143022_300", + "observed": True, + "files": [ + { + "name": "screen.png", + "size": 2048, + "sha256": "5f70bf18a086007016bb522ec180fd0b", + "status": "present", + } + ], +} + +OPERATIONS: list[OperationSpec] = [ + OperationSpec( + operation_id="observer.register", + method="POST", + rule="/app/observer/register", + summary="Register observer", + description=( + "Register a trusted local or paired-link observer and lock its stream " + "identity." + ), + request=RequestSpec( + fields=( + FieldSpec("platform", "string", required=True), + FieldSpec("hostname", "string", required=True), + FieldSpec("stream_type", "string", required=True), + FieldSpec("version", "string", required=True), + FieldSpec("label", "string"), + ), + example={ + "platform": "linux", + "hostname": "archon", + "stream_type": "desktop", + "version": "1.4.0", + "label": "Archon desktop", + }, + ), + responses=( + ResponseSpec( + status=200, + description="Observer registration descriptor.", + named_fields=( + FieldSpec("key", "string", required=True), + FieldSpec("prefix", "string", required=True), + FieldSpec("name", "string", required=True), + FieldSpec("ingest_url", "string", required=True), + FieldSpec("protocol_version", "integer", required=True), + ), + example={ + "key": "x7J7k2observerHandle", + "prefix": "x7J7k2ob", + "name": "archon", + "ingest_url": "/app/observer/ingest", + "protocol_version": 2, + }, + ), + _json_error( + 400, + ("invalid_segment_or_stream", "missing_required_field"), + "Register request failed validation.", + ), + _json_error( + 403, + ("local_request_only",), + "Register caller was not trusted localhost or paired link.", + ), + _json_error( + 500, + ("settings_operation_failed",), + "Observer record could not be saved.", + ), + ), + ), + OperationSpec( + operation_id="observer.ingestUpload", + method="POST", + rule="/app/observer/ingest", + summary="Upload observer segment files", + description=( + "Upload one capture segment as multipart form data and trigger local " + "observe processing." + ), + parameters=_OBSERVER_AUTH_PARAMS, + request=RequestSpec( + content_type="multipart/form-data", + fields=( + FieldSpec("segment", "string", required=True), + FieldSpec("day", "string", required=True), + FieldSpec( + "files", + "array", + required=True, + raw_schema={ + "type": "array", + "items": {"type": "string", "format": "binary"}, + }, + ), + FieldSpec("host", "string"), + FieldSpec("platform", "string"), + FieldSpec("meta", "string"), + ), + example={ + "segment": "143022_300", + "day": "20260618", + "files": ["screen.png", "audio.flac"], + "host": "archon", + "platform": "linux", + "meta": '{"facet":"work"}', + }, + ), + responses=( + ResponseSpec( + status=200, + description="Upload accepted, collision-adjusted, or duplicate.", + named_fields=( + FieldSpec("status", "string", required=True), + FieldSpec("segment", "string"), + FieldSpec("files", "array", item_type="string"), + FieldSpec("bytes", "integer"), + FieldSpec("existing_segment", "string"), + FieldSpec("message", "string"), + ), + example={ + "normal": { + "summary": "New segment accepted", + "value": { + "status": "ok", + "segment": "143022_300", + "files": ["screen.png", "audio.flac"], + "bytes": 524288, + }, + }, + "duplicate": { + "summary": "Duplicate segment", + "value": { + "status": "duplicate", + "existing_segment": "143022_300", + "message": "All files already received", + }, + }, + }, + ), + *_observer_auth_errors(), + _json_error( + 400, + ( + "ingest_no_files", + "invalid_day", + "invalid_segment_or_stream", + "missing_required_field", + ), + "Upload request failed validation.", + ), + _json_error( + 500, + ("ingest_storage_failed",), + "Uploaded file could not be stored.", + ), + _json_error( + 507, + ("ingest_storage_failed",), + "No segment slot was available after collision handling.", + ), + ), + ), + OperationSpec( + operation_id="observer.ingestEvent", + method="POST", + rule="/app/observer/ingest/event", + summary="Relay observer event", + description="Relay an observer-originated event onto the local Callosum bus.", + parameters=_OBSERVER_AUTH_PARAMS, + request=RequestSpec( + fields=( + FieldSpec("tract", "string", required=True), + FieldSpec("event", "string", required=True), + ), + example={ + "tract": "observe", + "event": "status", + "state": "recording", + }, + ), + responses=( + ResponseSpec( + status=200, + description="Event relayed.", + named_fields=(FieldSpec("status", "string", required=True),), + example={"status": "ok"}, + ), + *_observer_auth_errors(), + _json_error( + 400, + ("missing_required_field",), + "Tract or event was missing.", + ), + ), + ), + OperationSpec( + operation_id="observer.ingestManifest", + method="GET", + rule="/app/observer/ingest/manifest", + summary="List observer manifest days", + description="Return days with uploaded observer segment history.", + parameters=_OBSERVER_AUTH_PARAMS, + responses=( + ResponseSpec( + status=200, + description="Available manifest days keyed by day.", + named_fields=( + FieldSpec( + "days", + "object", + required=True, + raw_schema=_DAY_SEGMENT_COUNT_MAP, + ), + ), + example={"days": {"20260618": {"segments": 2}}}, + ), + *_observer_auth_errors(), + ), + ), + OperationSpec( + operation_id="observer.ingestManifestDay", + method="GET", + rule="/app/observer/ingest/manifest/", + summary="Read transfer manifest for a day", + description="Return file hashes and sizes for every segment on one day.", + parameters=( + *_OBSERVER_AUTH_PARAMS, + ParamSpec("day", "path", required=True, description="YYYYMMDD day."), + ), + responses=( + ResponseSpec( + status=200, + description="Day transfer manifest.", + named_fields=( + FieldSpec("version", "integer", required=True), + FieldSpec("day", "string", required=True), + FieldSpec("created_at", "integer", required=True), + FieldSpec("host", "string", required=True), + FieldSpec( + "segments", + "object", + required=True, + raw_schema=_MANIFEST_SEGMENT_MAP, + ), + ), + example={ + "version": 1, + "day": "20260618", + "created_at": 1781803200000, + "host": "archon", + "segments": { + "archon/143022_300": { + "files": [ + { + "name": "screen.png", + "sha256": "5f70bf18a086007016bb522ec180fd0b", + "size": 2048, + } + ] + } + }, + }, + ), + *_observer_auth_errors(), + _json_error( + 400, + ("invalid_day",), + "Day path parameter was invalid.", + ), + ), + ), + OperationSpec( + operation_id="observer.ingestSegments", + method="GET", + rule="/app/observer/ingest/segments/", + summary="List uploaded observer segments", + description=( + "Return segment upload history for one day. Protocol version 2 and " + "newer receive a collection envelope; older or absent protocol " + "headers receive a legacy bare array." + ), + parameters=( + *_OBSERVER_AUTH_PARAMS, + ParamSpec("day", "path", required=True, description="YYYYMMDD day."), + ParamSpec( + "stream", + "query", + required=False, + description="Fallback stream for legacy observer history rows.", + ), + ParamSpec( + "X-Solstone-Protocol-Version", + "header", + type="integer", + required=False, + description=( + ">= current protocol version returns the collection envelope; " + "lower or absent returns the legacy bare array." + ), + ), + ), + responses=( + ResponseSpec( + status=200, + description="Segment verification response.", + raw_schema=_SEGMENTS_RESPONSE_SCHEMA, + example={ + "legacy": { + "summary": "Legacy bare array", + "value": [_SEGMENT_ITEM_EXAMPLE], + }, + "v2": { + "summary": "Protocol v2 envelope", + "value": { + "items": [_SEGMENT_ITEM_EXAMPLE], + "total": 1, + "protocol_version": 2, + }, + }, + }, + ), + *_observer_auth_errors(), + _json_error( + 400, + ("invalid_day",), + "Day path parameter was invalid.", + ), + ), + ), + OperationSpec( + operation_id="observer.callosumStream", + method="GET", + rule="/app/observer/callosum", + summary="Stream Callosum events", + description=( + "Open an observer-authenticated Server-Sent Events feed. Frames: data " + "`data: {json}\\n\\n`; heartbeat `: heartbeat\\n\\n`; error " + "`event: error\\ndata: {Error}\\n\\n`." + ), + parameters=_OBSERVER_AUTH_PARAMS, + responses=( + ResponseSpec( + status=200, + description=( + "Callosum event stream. Data frames carry CallosumEvent JSON; " + "heartbeat frames are comments; error frames carry Error JSON." + ), + content_type="text/event-stream", + free_form=True, + raw_schema={"$ref": "#/components/schemas/CallosumEvent"}, + example={ + "tract": "observe", + "event": "observing", + "ts": 1781803200000, + "day": "20260618", + "segment": "143022_300", + }, + extensions={ + "x-sse-error-frame": { + "schema": {"$ref": "#/components/schemas/Error"}, + "x-reason-codes": [ + "auth_required", + "feature_unavailable", + "pl_revoked", + ], + "description": ( + "In-stream `event: error` frames emit only these. " + "auth_key_invalid is excluded here because key " + "validity is established at stream open " + "(routes.py:259-261); mid-stream re-checks only " + "cover observer missing/revoked/disabled " + "(routes.py:281-292)." + ), + } + }, + ), + _json_error( + 401, + ( + "auth_key_invalid", + "auth_required", + "feature_unavailable", + "pl_revoked", + ), + ( + "Stream-open observer auth failure; codes include 401 and " + "403 outcomes." + ), + ), + ), + ), +] + +__all__ = ["OPERATIONS"] diff --git a/solstone/convey/contract/__init__.py b/solstone/convey/contract/__init__.py new file mode 100644 index 000000000..edc1e90f3 --- /dev/null +++ b/solstone/convey/contract/__init__.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Generated OpenAPI contract assembly for Convey native clients.""" + +from __future__ import annotations + +from .assemble import CALLOSUM_REGISTRY, all_reason_codes, assemble, build_document +from .spec import FieldSpec, OperationSpec, ParamSpec, RequestSpec, ResponseSpec + +__all__ = [ + "CALLOSUM_REGISTRY", + "FieldSpec", + "OperationSpec", + "ParamSpec", + "RequestSpec", + "ResponseSpec", + "all_reason_codes", + "assemble", + "build_document", +] diff --git a/solstone/convey/contract/assemble.py b/solstone/convey/contract/assemble.py new file mode 100644 index 000000000..8a8894b47 --- /dev/null +++ b/solstone/convey/contract/assemble.py @@ -0,0 +1,356 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Assemble the generated OpenAPI contract for Convey native clients.""" + +from __future__ import annotations + +import importlib +import re +from collections.abc import Iterable +from typing import Any + +import solstone.convey.reasons as reasons +from solstone.convey.reasons import Reason + +from .spec import FieldSpec, OperationSpec, ParamSpec, RequestSpec, ResponseSpec + +FRAGMENT_MODULES = [ + "solstone.apps.link.contract", + "solstone.apps.observer.contract", + "solstone.convey.push_contract", +] + +CALLOSUM_REGISTRY: dict[str, list[str]] = { + "activity": ["live", "recorded"], + "chat": [ + "owner_message", + "sol_message", + "talent_spawned", + "talent_finished", + "talent_errored", + "reflection_ready", + "chat_queue_depth", + "chat_error", + "sol_chat_request", + "sol_chat_request_superseded", + "owner_chat_open", + "owner_chat_dismissed", + "support_draft", + "result", + "support_submit_claim", + ], + "cortex": [ + "request", + "start", + "thinking", + "tool_start", + "tool_end", + "finish", + "error", + "talent_updated", + "info", + "status", + ], + "importer": ["started", "status", "completed", "error"], + "logs": ["exec", "line", "exit"], + "navigate": ["request"], + "notification": ["*"], + "observe": [ + "status", + "observing", + "detected", + "described", + "transcribed", + "observed", + ], + "supervisor": ["started", "stopped", "restarting", "status", "queue"], + "sync": ["status"], + "think": [ + "started", + "status", + "group_started", + "group_completed", + "talent_started", + "talent_completed", + "completed", + "segments_started", + "segments_completed", + ], +} + +_RULE_PARAM_RE = re.compile(r"<(?:[^:<>]+:)?([^<>]+)>") + + +def all_reason_codes() -> list[str]: + """Return all owner-facing Reason codes from solstone.convey.reasons.""" + + return sorted( + {value.code for value in vars(reasons).values() if isinstance(value, Reason)} + ) + + +def rule_to_openapi_path(rule: str) -> str: + """Convert a Werkzeug rule path to OpenAPI path-parameter syntax.""" + + return _RULE_PARAM_RE.sub(r"{\1}", rule) + + +def _field_schema(field: FieldSpec) -> dict[str, Any]: + if field.raw_schema is not None: + return dict(field.raw_schema) + + schema: dict[str, Any] = {"type": field.type} + if field.description: + schema["description"] = field.description + if field.type == "array": + schema["items"] = ( + {"type": field.item_type} if field.item_type is not None else {} + ) + return schema + + +def _object_schema(fields: Iterable[FieldSpec], *, free_form: bool = False) -> dict: + if free_form: + return {"type": "object", "additionalProperties": True} + + field_tuple = tuple(fields) + schema: dict[str, Any] = { + "type": "object", + "additionalProperties": True, + "properties": {field.name: _field_schema(field) for field in field_tuple}, + } + required = [field.name for field in field_tuple if field.required] + if required: + schema["required"] = required + return schema + + +def _request_schema(request: RequestSpec) -> dict[str, Any]: + if request.raw_schema is not None: + return dict(request.raw_schema) + return _object_schema(request.fields) + + +def _response_schema(response: ResponseSpec) -> dict[str, Any]: + if response.raw_schema is not None: + return dict(response.raw_schema) + return _object_schema(response.named_fields, free_form=response.free_form) + + +def _has_response_content(response: ResponseSpec) -> bool: + return bool(response.raw_schema or response.named_fields or response.free_form) + + +def _is_named_examples(example: object) -> bool: + if not isinstance(example, dict) or not example: + return False + return all( + isinstance(value, dict) and "value" in value for value in example.values() + ) + + +def _apply_example(media: dict[str, Any], example: object | None) -> None: + if example is None: + return + if _is_named_examples(example): + media["examples"] = example + return + media["example"] = example + + +def _parameter(param: ParamSpec) -> dict[str, Any]: + required = True if param.location == "path" else param.required + result: dict[str, Any] = { + "name": param.name, + "in": param.location, + "required": required, + "schema": {"type": param.type}, + } + if param.description: + result["description"] = param.description + return result + + +def _request_body(request: RequestSpec) -> dict[str, Any]: + media: dict[str, Any] = {"schema": _request_schema(request)} + if request.example is not None: + media["example"] = request.example + + body: dict[str, Any] = { + "content": { + request.content_type: media, + } + } + if request.description: + body["description"] = request.description + if any(field.required for field in request.fields): + body["required"] = True + return body + + +def _response(response: ResponseSpec) -> dict[str, Any]: + result: dict[str, Any] = {"description": response.description} + + if response.reason_codes: + result["content"] = { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"}, + } + } + result["x-reason-codes"] = sorted(set(response.reason_codes)) + elif _has_response_content(response): + media: dict[str, Any] = {"schema": _response_schema(response)} + _apply_example(media, response.example) + result["content"] = { + response.content_type: media, + } + + if response.extensions: + result.update(response.extensions) + return result + + +def _operation(operation: OperationSpec) -> dict[str, Any]: + area = operation.operation_id.split(".", 1)[0] + result: dict[str, Any] = { + "operationId": operation.operation_id, + "summary": operation.summary, + "description": operation.description, + "tags": [area], + "responses": { + str(response.status): _response(response) + for response in operation.responses + }, + } + if operation.parameters: + result["parameters"] = [_parameter(param) for param in operation.parameters] + if operation.request is not None: + result["requestBody"] = _request_body(operation.request) + if operation.auth: + result["x-auth"] = operation.auth + return result + + +def _components() -> dict[str, Any]: + segment_file = { + "type": "object", + "additionalProperties": True, + "properties": { + "name": {"type": "string"}, + "size": {"type": "integer"}, + "sha256": {"type": "string"}, + "status": {"type": "string"}, + "submitted_name": {"type": "string"}, + "current_path": {"type": "string"}, + }, + "required": ["name", "size", "sha256", "status"], + } + segment_item = { + "type": "object", + "additionalProperties": True, + "properties": { + "key": {"type": "string"}, + "observed": {"type": "boolean"}, + "files": { + "type": "array", + "items": {"$ref": "#/components/schemas/SegmentFile"}, + }, + "original_key": {"type": "string"}, + }, + "required": ["key", "observed", "files"], + } + segments_envelope = { + "type": "object", + "additionalProperties": True, + "properties": { + "items": { + "type": "array", + "items": {"$ref": "#/components/schemas/SegmentItem"}, + }, + "total": {"type": "integer"}, + "protocol_version": {"type": "integer"}, + }, + "required": ["items", "total", "protocol_version"], + } + return { + "schemas": { + "CallosumEvent": { + "type": "object", + "additionalProperties": True, + "properties": { + "tract": {"type": "string"}, + "event": {"type": "string"}, + "ts": {"type": "integer"}, + }, + "required": ["tract", "event", "ts"], + }, + "Error": { + "type": "object", + "additionalProperties": True, + "properties": { + "error": {"type": "string"}, + "reason_code": { + "type": "string", + "enum": all_reason_codes(), + }, + "detail": {"type": "string"}, + }, + "required": ["error", "reason_code", "detail"], + }, + "SegmentFile": segment_file, + "SegmentItem": segment_item, + "SegmentsEnvelope": segments_envelope, + } + } + + +def assemble(fragments: list[list[OperationSpec]]) -> dict[str, Any]: + """Assemble fragment operations into the generated OpenAPI document.""" + + document: dict[str, Any] = { + "openapi": "3.1.0", + "info": { + "title": "Solstone Convey Native-Client Contract", + "version": "1.0.0", + "x-generated": True, + "x-generated-by": "make openapi (scripts/build_openapi_contract.py)", + "description": ( + "Generated native-client contract for the Convey HTTP surface. " + "Regenerate with `make openapi`; do not hand-edit this file." + ), + }, + "paths": {}, + "components": _components(), + "x-callosum-registry": { + key: CALLOSUM_REGISTRY[key] for key in sorted(CALLOSUM_REGISTRY) + }, + } + + for operations in fragments: + for operation in operations: + path = rule_to_openapi_path(operation.rule) + methods = document["paths"].setdefault(path, {}) + methods[operation.method.lower()] = _operation(operation) + + return document + + +def build_document() -> dict[str, Any]: + """Import the explicit fragment list and assemble the OpenAPI document.""" + + fragments: list[list[OperationSpec]] = [] + for module_name in FRAGMENT_MODULES: + module = importlib.import_module(module_name) + fragments.append(list(module.OPERATIONS)) + return assemble(fragments) + + +__all__ = [ + "CALLOSUM_REGISTRY", + "FRAGMENT_MODULES", + "all_reason_codes", + "assemble", + "build_document", + "rule_to_openapi_path", +] diff --git a/solstone/convey/contract/diff.py b/solstone/convey/contract/diff.py new file mode 100644 index 000000000..a12d9dbc5 --- /dev/null +++ b/solstone/convey/contract/diff.py @@ -0,0 +1,272 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Contract-level OpenAPI comparison helpers.""" + +from __future__ import annotations + +from collections.abc import Iterable + +_HTTP_METHODS = { + "delete", + "get", + "head", + "options", + "patch", + "post", + "put", + "trace", +} + + +def classify_changes(current: dict, committed: dict) -> list[str]: + """Return human-readable breaking changes between two OpenAPI documents.""" + + breaking: list[str] = [] + current_ops = _operation_map(current) + committed_ops = _operation_map(committed) + + for path, method in sorted(set(committed_ops) - set(current_ops)): + breaking.append(f"{method.upper()} {path}: removed endpoint") + + current_operation_ids = _operation_ids(current_ops.values()) + committed_operation_ids = _operation_ids(committed_ops.values()) + for operation_id in sorted(committed_operation_ids - current_operation_ids): + breaking.append(f"{operation_id}: removed or renamed operationId") + + for key in sorted(set(committed_ops) & set(current_ops)): + current_op = current_ops[key] + committed_op = committed_ops[key] + label = _operation_label(committed_op, *key) + + breaking.extend( + _removed_request_fields(label, current_op, committed_op), + ) + breaking.extend( + _new_required_request_fields(label, current_op, committed_op), + ) + breaking.extend( + _removed_parameters(label, current_op, committed_op), + ) + breaking.extend( + _removed_response_fields(label, current_op, committed_op), + ) + breaking.extend( + _removed_reason_codes(label, current_op, committed_op), + ) + + return breaking + + +def undeclared_top_level_fields(allowed: set[str], actual: dict) -> list[str]: + """Return top-level response keys that are not declared in the contract.""" + + return sorted(set(actual) - allowed) + + +def _operation_map(document: dict) -> dict[tuple[str, str], dict]: + operations: dict[tuple[str, str], dict] = {} + paths = document.get("paths", {}) + if not isinstance(paths, dict): + return operations + for path, methods in paths.items(): + if not isinstance(path, str) or not isinstance(methods, dict): + continue + for method, operation in methods.items(): + method_name = str(method).lower() + if method_name not in _HTTP_METHODS or not isinstance(operation, dict): + continue + operations[(path, method_name)] = operation + return operations + + +def _operation_ids(operations: Iterable[dict]) -> set[str]: + return { + operation_id + for operation in operations + if isinstance(operation_id := operation.get("operationId"), str) + } + + +def _operation_label(operation: dict, path: str, method: str) -> str: + operation_id = operation.get("operationId") + if isinstance(operation_id, str) and operation_id: + return operation_id + return f"{method.upper()} {path}" + + +def _single_content_schema(container: dict) -> dict: + content = container.get("content", {}) + if not isinstance(content, dict) or not content: + return {} + media = next(iter(content.values())) + if not isinstance(media, dict): + return {} + schema = media.get("schema", {}) + return schema if isinstance(schema, dict) else {} + + +def _request_schema(operation: dict) -> dict: + request_body = operation.get("requestBody", {}) + if not isinstance(request_body, dict): + return {} + return _single_content_schema(request_body) + + +def _schema_properties(schema: dict) -> set[str] | None: + properties = schema.get("properties") + if not isinstance(properties, dict): + return None + return {name for name in properties if isinstance(name, str)} + + +def _schema_required(schema: dict) -> set[str]: + required = schema.get("required", []) + if not isinstance(required, list): + return set() + return {name for name in required if isinstance(name, str)} + + +def _removed_request_fields( + label: str, + current_op: dict, + committed_op: dict, +) -> list[str]: + committed_properties = _schema_properties(_request_schema(committed_op)) + if committed_properties is None: + return [] + current_properties = _schema_properties(_request_schema(current_op)) or set() + return [ + f"{label}: removed request field '{field}'" + for field in sorted(committed_properties - current_properties) + ] + + +def _new_required_request_fields( + label: str, + current_op: dict, + committed_op: dict, +) -> list[str]: + current_required = _schema_required(_request_schema(current_op)) + committed_required = _schema_required(_request_schema(committed_op)) + return [ + f"{label}: new required request field '{field}'" + for field in sorted(current_required - committed_required) + ] + + +def _parameter_keys(operation: dict) -> set[tuple[str, str]]: + parameters = operation.get("parameters", []) + if not isinstance(parameters, list): + return set() + keys: set[tuple[str, str]] = set() + for parameter in parameters: + if not isinstance(parameter, dict): + continue + name = parameter.get("name") + location = parameter.get("in") + if isinstance(name, str) and isinstance(location, str): + keys.add((name, location)) + return keys + + +def _removed_parameters( + label: str, + current_op: dict, + committed_op: dict, +) -> list[str]: + removed = _parameter_keys(committed_op) - _parameter_keys(current_op) + return [ + f"{label}: removed parameter '{name}' in {location}" + for name, location in sorted(removed) + ] + + +def _response_schema(response: dict) -> dict: + if not isinstance(response, dict): + return {} + return _single_content_schema(response) + + +def _removed_response_fields( + label: str, + current_op: dict, + committed_op: dict, +) -> list[str]: + breaking: list[str] = [] + current_responses = current_op.get("responses", {}) + committed_responses = committed_op.get("responses", {}) + if not isinstance(current_responses, dict) or not isinstance( + committed_responses, dict + ): + return breaking + for status, committed_response in sorted(committed_responses.items()): + if not isinstance(committed_response, dict): + continue + committed_properties = _schema_properties( + _response_schema(committed_response), + ) + if committed_properties is None: + continue + current_response = current_responses.get(status, {}) + current_properties = ( + _schema_properties(_response_schema(current_response)) or set() + ) + for field in sorted(committed_properties - current_properties): + breaking.append(f"{label}: removed response field '{field}'") + return breaking + + +def _reason_codes(response: dict) -> set[str]: + raw_codes = response.get("x-reason-codes", []) if isinstance(response, dict) else [] + if not isinstance(raw_codes, list): + return set() + return {code for code in raw_codes if isinstance(code, str)} + + +def _sse_reason_codes(response: dict) -> set[str]: + if not isinstance(response, dict): + return set() + frame = response.get("x-sse-error-frame", {}) + if not isinstance(frame, dict): + return set() + raw_codes = frame.get("x-reason-codes", []) + if not isinstance(raw_codes, list): + return set() + return {code for code in raw_codes if isinstance(code, str)} + + +def _removed_reason_codes( + label: str, + current_op: dict, + committed_op: dict, +) -> list[str]: + breaking: list[str] = [] + current_responses = current_op.get("responses", {}) + committed_responses = committed_op.get("responses", {}) + if not isinstance(current_responses, dict) or not isinstance( + committed_responses, dict + ): + return breaking + + for status, committed_response in sorted(committed_responses.items()): + if not isinstance(committed_response, dict): + continue + current_response = current_responses.get(status, {}) + for code in sorted( + _reason_codes(committed_response) - _reason_codes(current_response) + ): + breaking.append( + f"{label}: removed referenced reason code '{code}' from response {status}" + ) + for code in sorted( + _sse_reason_codes(committed_response) - _sse_reason_codes(current_response) + ): + breaking.append( + f"{label}: removed SSE error-frame reason code '{code}' " + f"from response {status}" + ) + return breaking + + +__all__ = ["classify_changes", "undeclared_top_level_fields"] diff --git a/solstone/convey/contract/spec.py b/solstone/convey/contract/spec.py new file mode 100644 index 000000000..1236aa9a1 --- /dev/null +++ b/solstone/convey/contract/spec.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Small frozen DSL for the generated native-client OpenAPI contract.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class FieldSpec: + name: str + type: str + required: bool = False + description: str = "" + item_type: str | None = None + raw_schema: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class ParamSpec: + name: str + location: str + type: str = "string" + required: bool = False + description: str = "" + + +@dataclass(frozen=True) +class RequestSpec: + content_type: str = "application/json" + fields: tuple[FieldSpec, ...] = field(default=()) + raw_schema: dict[str, Any] | None = None + example: dict[str, Any] | None = None + description: str = "" + + +@dataclass(frozen=True) +class ResponseSpec: + status: int + description: str = "" + content_type: str = "application/json" + named_fields: tuple[FieldSpec, ...] = field(default=()) + free_form: bool = False + raw_schema: dict[str, Any] | None = None + reason_codes: tuple[str, ...] = field(default=()) + example: object | None = None + extensions: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class OperationSpec: + operation_id: str + method: str + rule: str + summary: str + description: str + request: RequestSpec | None = None + parameters: tuple[ParamSpec, ...] = field(default=()) + responses: tuple[ResponseSpec, ...] = field(default=()) + auth: str = "" + + +__all__ = [ + "FieldSpec", + "OperationSpec", + "ParamSpec", + "RequestSpec", + "ResponseSpec", +] diff --git a/solstone/convey/push_contract.py b/solstone/convey/push_contract.py new file mode 100644 index 000000000..78bd29d0e --- /dev/null +++ b/solstone/convey/push_contract.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""OpenAPI fragment for the native-client push routes.""" + +from __future__ import annotations + +from solstone.convey.contract import FieldSpec, OperationSpec, RequestSpec, ResponseSpec + + +def _json_error( + status: int, + reason_codes: tuple[str, ...], + description: str, +) -> ResponseSpec: + return ResponseSpec( + status=status, + description=description, + reason_codes=reason_codes, + ) + + +OPERATIONS: list[OperationSpec] = [ + OperationSpec( + operation_id="push.register", + method="POST", + rule="/api/push/register", + summary="Register push device", + description=( + "Register or replace an iOS push token for the paired device " + "fingerprint on the current connection." + ), + request=RequestSpec( + fields=( + FieldSpec("device_token", "string", required=True), + FieldSpec("bundle_id", "string", required=True), + FieldSpec("environment", "string", required=True), + FieldSpec("platform", "string", required=True), + ), + example={ + "device_token": "abcdef0123456789", + "bundle_id": "org.solpbc.solstone-swift", + "environment": "development", + "platform": "ios", + }, + ), + responses=( + ResponseSpec( + status=200, + description="Push token registered.", + named_fields=( + FieldSpec("registered", "boolean", required=True), + FieldSpec("device_count", "integer", required=True), + ), + example={"registered": True, "device_count": 1}, + ), + _json_error( + 400, + ("invalid_json_request", "push_request_invalid"), + "Push registration request failed validation.", + ), + _json_error( + 403, + ("pl_revoked",), + "Access gate rejected a revoked paired-link identity.", + ), + ), + ), + OperationSpec( + operation_id="push.unregister", + method="DELETE", + rule="/api/push/register", + summary="Unregister push device", + description="Remove the push token for the current paired device fingerprint.", + responses=( + ResponseSpec( + status=200, + description="Push token removal result.", + named_fields=( + FieldSpec("removed", "boolean", required=True), + FieldSpec("device_count", "integer", required=True), + ), + example={"removed": True, "device_count": 0}, + ), + _json_error( + 400, + ("push_request_invalid",), + "No paired device fingerprint was available on the connection.", + ), + _json_error( + 403, + ("pl_revoked",), + "Access gate rejected a revoked paired-link identity.", + ), + ), + ), +] + +__all__ = ["OPERATIONS"] diff --git a/tests/test_openapi_contract.py b/tests/test_openapi_contract.py new file mode 100644 index 000000000..0503a4ab7 --- /dev/null +++ b/tests/test_openapi_contract.py @@ -0,0 +1,349 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import io +from copy import deepcopy +from pathlib import Path +from typing import Any + +import pytest + +from solstone.apps.link.contract import OPERATIONS as LINK_OPERATIONS +from solstone.apps.observer.contract import OPERATIONS as OBSERVER_OPERATIONS +from solstone.convey import create_app +from solstone.convey.contract.assemble import build_document +from solstone.convey.contract.diff import ( + classify_changes, + undeclared_top_level_fields, +) +from solstone.convey.push_contract import OPERATIONS as PUSH_OPERATIONS +from solstone.convey.secure_listener.identity import ConveyIdentity +from tests._baseline_harness import ( + isolated_app_env, + mark_setup_complete, + prepare_isolated_journal, +) + +CONTRACTED_PATHS = { + "/api/push/register", + "/app/link/api/status", + "/app/link/local-endpoints", + "/app/link/pair", + "/app/link/pair-start", + "/app/link/unpair", + "/app/observer/callosum", + "/app/observer/ingest", + "/app/observer/ingest/event", + "/app/observer/ingest/manifest", + "/app/observer/ingest/manifest/{day}", + "/app/observer/ingest/segments/{day}", + "/app/observer/register", +} + +REGISTER_OBSERVER_PAYLOAD = { + "platform": "linux", + "hostname": "contract-host", + "stream_type": "desktop", + "version": "1", +} + +PUSH_FINGERPRINT = "sha256:" + ("a" * 64) + + +@pytest.fixture +def contract_app(tmp_path: Path): + journal = prepare_isolated_journal(tmp_path / "journal") + mark_setup_complete(journal) + with isolated_app_env(journal): + app = create_app(journal=str(journal.resolve())) + app.config["TESTING"] = True + yield app, app.test_client(), journal + + +def _all_operations(): + return [*LINK_OPERATIONS, *OBSERVER_OPERATIONS, *PUSH_OPERATIONS] + + +def _operation(document: dict[str, Any], operation_id: str) -> dict[str, Any]: + for path_item in document["paths"].values(): + for operation in path_item.values(): + if operation.get("operationId") == operation_id: + return operation + raise AssertionError(f"operation not found: {operation_id}") + + +def _response_schema( + document: dict[str, Any], + operation_id: str, + status: int, +) -> dict[str, Any]: + response = _operation(document, operation_id)["responses"][str(status)] + content = response.get("content", {}) + if not content: + return {} + media = next(iter(content.values())) + return media.get("schema", {}) + + +def _declared_response_fields( + document: dict[str, Any], + operation_id: str, + status: int = 200, +) -> set[str]: + schema = _response_schema(document, operation_id, status) + return set(schema.get("properties", {})) + + +def _global_reason_codes(document: dict[str, Any]) -> set[str]: + reason_code = document["components"]["schemas"]["Error"]["properties"][ + "reason_code" + ] + return set(reason_code["enum"]) + + +def _assert_structured_error(body: dict[str, Any], document: dict[str, Any]) -> None: + assert {"error", "reason_code", "detail"}.issubset(body) + assert body["reason_code"] in _global_reason_codes(document) + + +def _register_observer(client) -> str: + response = client.post("/app/observer/register", json=REGISTER_OBSERVER_PAYLOAD) + assert response.status_code == 200, response.get_data(as_text=True) + body = response.get_json() + assert isinstance(body, dict) + return str(body["key"]) + + +def _push_identity() -> ConveyIdentity: + return ConveyIdentity( + mode="dl", + fingerprint=PUSH_FINGERPRINT, + device_label="Owner phone", + paired_at="2026-06-18T00:00:00Z", + session_id="contract-test", + ) + + +def test_all_fragment_routes_resolve(contract_app): + app, _client, _journal = contract_app + assert build_document()["paths"] + + for operation in _all_operations(): + matches = [ + rule + for rule in app.url_map.iter_rules() + if rule.rule == operation.rule and operation.method.upper() in rule.methods + ] + assert matches, f"{operation.method} {operation.rule} did not resolve" + + +def test_observer_auth_both_header_forms(contract_app): + _app, client, _journal = contract_app + document = build_document() + allowed = _declared_response_fields(document, "observer.ingestManifest") + key = _register_observer(client) + + responses = [ + client.get( + "/app/observer/ingest/manifest", + headers={"Authorization": f"Bearer {key}"}, + ), + client.get( + "/app/observer/ingest/manifest", + headers={"X-Solstone-Observer": key}, + ), + ] + + for response in responses: + assert response.status_code == 200 + body = response.get_json() + assert isinstance(body, dict) + assert undeclared_top_level_fields(allowed, body) == [] + + +def test_segments_protocol_version_shape(contract_app): + _app, client, _journal = contract_app + key = _register_observer(client) + + legacy = client.get( + "/app/observer/ingest/segments/20250103", + headers={"Authorization": f"Bearer {key}"}, + ) + assert legacy.status_code == 200 + assert isinstance(legacy.get_json(), list) + + current = client.get( + "/app/observer/ingest/segments/20250103", + headers={ + "Authorization": f"Bearer {key}", + "X-Solstone-Protocol-Version": "2", + }, + ) + assert current.status_code == 200 + body = current.get_json() + assert isinstance(body, dict) + assert {"items", "total", "protocol_version"}.issubset(body) + + +def test_multipart_and_json_parsing(contract_app): + _app, client, _journal = contract_app + document = build_document() + key = _register_observer(client) + + upload = client.post( + "/app/observer/ingest", + headers={"X-Solstone-Observer": key}, + data={ + "day": "20250103", + "segment": "120000_300", + "files": (io.BytesIO(b"contract upload"), "audio.flac"), + }, + content_type="multipart/form-data", + ) + assert upload.status_code != 415 + upload_body = upload.get_json() + assert isinstance(upload_body, dict) + if upload.status_code == 200: + assert {"status", "segment", "files", "bytes"}.issubset(upload_body) + allowed = _declared_response_fields(document, "observer.ingestUpload") + assert undeclared_top_level_fields(allowed, upload_body) == [] + else: + _assert_structured_error(upload_body, document) + + push = client.post( + "/api/push/register", + json={ + "device_token": "A" * 64, + "bundle_id": "org.solpbc.solstone-swift", + "environment": "development", + "platform": "ios", + }, + environ_overrides={"pl.identity": _push_identity()}, + ) + assert push.status_code == 200 + push_body = push.get_json() + assert isinstance(push_body, dict) + assert push_body == {"registered": True, "device_count": 1} + + +def test_structured_error_shape(contract_app): + _app, client, _journal = contract_app + document = build_document() + + response = client.get("/app/observer/ingest/manifest") + + assert response.status_code == 401 + body = response.get_json() + assert isinstance(body, dict) + _assert_structured_error(body, document) + + +def test_named_response_no_drift(contract_app): + _app, client, _journal = contract_app + document = build_document() + allowed = _declared_response_fields(document, "link.status") + + response = client.get("/app/link/api/status") + + assert response.status_code == 200 + body = response.get_json() + assert isinstance(body, dict) + assert undeclared_top_level_fields(allowed, body) == [] + assert allowed <= set(body) + + +def test_no_r0_routes_in_artifact(): + document = build_document() + + assert "/api/config/convey" not in document["paths"] + assert "/api/system/status" not in document["paths"] + assert "/api/chat/session" not in document["paths"] + assert set(document["paths"]) == CONTRACTED_PATHS + assert len(document["paths"]) == 13 + + +def test_all_referenced_reason_codes_are_global(): + document = build_document() + global_codes = _global_reason_codes(document) + referenced_codes: set[str] = set() + + for path_item in document["paths"].values(): + for operation in path_item.values(): + for response in operation.get("responses", {}).values(): + referenced_codes.update(response.get("x-reason-codes", [])) + sse_error_frame = response.get("x-sse-error-frame", {}) + referenced_codes.update(sse_error_frame.get("x-reason-codes", [])) + + assert referenced_codes - global_codes == set() + + +def test_scenario_removed_named_field_is_breaking(): + committed = build_document() + current = deepcopy(committed) + properties = _response_schema(current, "observer.register", 200)["properties"] + properties.pop("prefix") + + breaking = classify_changes(current, committed) + + assert any( + "observer.register: removed response field 'prefix'" in item + for item in breaking + ) + + +def test_scenario_added_optional_field_is_silent(): + committed = build_document() + current = deepcopy(committed) + properties = _response_schema(current, "observer.register", 200)["properties"] + properties["optional_future"] = {"type": "string"} + + assert classify_changes(current, committed) == [] + + +def test_scenario_new_required_request_field_is_breaking(): + committed = build_document() + current = deepcopy(committed) + schema = _operation(current, "observer.register")["requestBody"]["content"][ + "application/json" + ]["schema"] + schema["properties"]["new_required"] = {"type": "string"} + schema["required"].append("new_required") + + breaking = classify_changes(current, committed) + + assert any( + "observer.register: new required request field 'new_required'" in item + for item in breaking + ) + + +def test_scenario_removed_referenced_reason_code_is_breaking(): + committed = build_document() + current = deepcopy(committed) + response = _operation(current, "link.status")["responses"]["403"] + response["x-reason-codes"].remove("pl_revoked") + + breaking = classify_changes(current, committed) + + assert any( + "link.status: removed referenced reason code 'pl_revoked'" in item + for item in breaking + ) + + +def test_scenario_global_enum_addition_is_not_breaking(): + committed = build_document() + current = deepcopy(committed) + enum = current["components"]["schemas"]["Error"]["properties"]["reason_code"][ + "enum" + ] + enum.append("future_unreferenced_code") + + assert classify_changes(current, committed) == [] + + +def test_scenario_undeclared_field_then_declared(): + assert undeclared_top_level_fields({"a", "b"}, {"a": 1, "c": 2}) == ["c"] + assert undeclared_top_level_fields({"a", "b", "c"}, {"a": 1, "c": 2}) == [] -- 2.51.2