From 7cf2845ca3522232cf9af35515d3ba29e88cc052 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 14 Oct 2026 16:24:46 -0700 Subject: [PATCH 1/3] Reject workspace creation outside the request scope --- docs/api-reference.md | 13 +++-- docs/operations-runbook.md | 7 ++- openapi.json | 2 +- sdks/java/README.md | 2 +- sdks/python/README.md | 2 +- sdks/python/src/flycanon_sdk/_client.py | 6 +- .../web/controllers/workspaces_controller.py | 18 +++--- src/flycanon/web/conventions/exceptions.py | 6 +- tests/unit/test_workspaces_controller.py | 58 ++++++++++++++----- 9 files changed, 78 insertions(+), 36 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index cc61b3b..ba4a6f4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -164,13 +164,16 @@ The `/api/v1/ingest-jobs/*` surface parallels flyradar's ## Workspaces -CRUD over the `canon_workspaces` table. All six endpoints -require the standard tenant headers; list / get / update / close / -purge scope by `X-Tenant-Id`. +CRUD over the `canon_workspaces` table. All six endpoints require +the standard tenant and workspace headers. For creation, the body +`id` must equal `X-Workspace-Id`. A mismatch returns +`400 workspace_scope_mismatch` before any row or event is written. +Production RLS restricts row visibility to the tenant and workspace +in those headers; the tenant-wide list exception is described below. | Method | Path | Description | |--------|------|-------------| -| `POST` | `/api/v1/workspaces` | Create. Body: `WorkspaceCreate`. Returns `WorkspaceSummary`. | +| `POST` | `/api/v1/workspaces` | Create. Body: `WorkspaceCreate`; `id` must match `X-Workspace-Id`. Returns `201` with `WorkspaceSpec`. | | `GET` | `/api/v1/workspaces` | List within tenant. Query: `status`, `limit`, `offset`. | | `GET` | `/api/v1/workspaces/{id}` | Fetch one. 404 → `resource_not_found`. | | `PATCH` | `/api/v1/workspaces/{id}` | Sparse update. Body: `WorkspaceUpdate`. | @@ -351,7 +354,7 @@ Field-level validation details land under `errors[]`. | 400 | `url_fetch_forbidden_host` / `url_fetch_unsupported_scheme` | `uri` targets a refused host / a non-http scheme. | | 413 | `url_fetch_too_large` | The origin's document exceeds `FLYCANON_MAX_BYTES`. | | 502 | `url_fetch_http_error` / `url_fetch_failed` / `url_fetch_too_many_redirects` | The origin answered 4xx/5xx, could not be reached, or redirected more than five times. | -| 400 | `workspace_scope_mismatch` | `POST /workspaces/{id}:purge` with an `X-Workspace-Id` that differs from the path id. | +| 400 | `workspace_scope_mismatch` | Workspace creation with a body `id` different from `X-Workspace-Id`, or `POST /workspaces/{id}:purge` with a different path id. | | 404 | `source_not_found` | `DELETE`/`PUT` on an unknown source id (either tier). | | 400 | `invalid_request` | Pydantic validation, missing-field `ValueError` raised inside a controller helper. | | 404 | `resource_not_found` | Generic missing resource (knowledge item, source, conversation, candidate, job, workspace). | diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md index 1cd80c8..6237390 100644 --- a/docs/operations-runbook.md +++ b/docs/operations-runbook.md @@ -159,9 +159,14 @@ mutation emits an event on `canon.workspaces.v1` so consumers ### Create a workspace +Set `X-Workspace-Id` to the same value as the body `id`. A mismatch +returns `400 workspace_scope_mismatch` before any row or event is +written. This check preserves the production RLS scope; it does not +grant access to a tenant or workspace. + ```bash curl -X POST https://canon.example.com/api/v1/workspaces \ - -H "Authorization: Bearer $OPERATOR_JWT" \ + -H "X-API-Key: $FLYCANON_API_KEY" \ -H "X-Tenant-Id: acme" \ -H "X-Workspace-Id: ws-prod" \ -H "Content-Type: application/json" \ diff --git a/openapi.json b/openapi.json index 452ab1c..b62190b 100644 --- a/openapi.json +++ b/openapi.json @@ -8719,7 +8719,7 @@ ] }, "post": { - "description": "The body carries the caller-chosen workspace id (slug,\n``<= 64`` chars). Tenant id comes from the ``X-Tenant-Id``\nheader -- the body intentionally does not accept it so a\ncaller cannot create workspaces under another tenant.\n\nReturns ``201 Created`` with the persisted :class:`WorkspaceSpec`.", + "description": "The body carries the caller-chosen workspace id (slug,\n``<= 64`` chars). Tenant id comes from the ``X-Tenant-Id``\nheader. The body id must equal ``X-Workspace-Id``; a mismatch\nreturns ``400 workspace_scope_mismatch`` before any write.\n\nReturns ``201 Created`` with the persisted :class:`WorkspaceSpec`.", "operationId": "create", "parameters": [ { diff --git a/sdks/java/README.md b/sdks/java/README.md index aaa0a9b..a9f11b9 100644 --- a/sdks/java/README.md +++ b/sdks/java/README.md @@ -181,7 +181,7 @@ at the boundary as a typed exception. | Method | Description | |--------|-------------| -| `createWorkspace(WorkspaceCreate spec) -> WorkspaceSpec` | Create a workspace under the caller's tenant. | +| `createWorkspace(WorkspaceCreate spec) -> WorkspaceSpec` | Create a workspace under the caller's tenant. Configure the client's workspace header to equal `spec.id()`; a mismatch returns `400 workspace_scope_mismatch` before any write. | | `listWorkspaces() -> List` | List every workspace owned by the caller's tenant (`created_at DESC`). | | `getWorkspace(String workspaceId) -> WorkspaceSpec` | Fetch a single workspace by id. | | `updateWorkspace(String workspaceId, WorkspaceUpdate patch) -> WorkspaceSpec` | Sparse PATCH; only present fields are applied. | diff --git a/sdks/python/README.md b/sdks/python/README.md index 5fdf036..f98b5aa 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -110,7 +110,7 @@ boundary. | Method | Description | |--------|-------------| -| `create_workspace(spec: WorkspaceCreate) -> WorkspaceSpec` | Create a workspace under the caller's tenant. | +| `create_workspace(spec: WorkspaceCreate) -> WorkspaceSpec` | Create a workspace under the caller's tenant. The client's `workspace_id` must equal `spec.id`; a mismatch returns `400 workspace_scope_mismatch` before any write. | | `list_workspaces() -> list[WorkspaceSummary]` | List every workspace owned by the caller's tenant (`created_at DESC`). | | `get_workspace(workspace_id) -> WorkspaceSpec` | Fetch a single workspace by id. | | `update_workspace(workspace_id, patch: WorkspaceUpdate) -> WorkspaceSpec` | Sparse PATCH; only present fields are applied. | diff --git a/sdks/python/src/flycanon_sdk/_client.py b/sdks/python/src/flycanon_sdk/_client.py index 6c9731a..2978dc8 100644 --- a/sdks/python/src/flycanon_sdk/_client.py +++ b/sdks/python/src/flycanon_sdk/_client.py @@ -877,8 +877,10 @@ async def create_workspace(self, spec: WorkspaceCreate) -> WorkspaceSpec: """Create a workspace under the caller's tenant. The id comes from ``spec.id`` (caller-chosen slug); the tenant - comes from the ``X-Tenant-Id`` header. Returns ``201 Created`` - with the persisted :class:`WorkspaceSpec`. + comes from the ``X-Tenant-Id`` header. Configure the client's + ``workspace_id`` to equal ``spec.id``. A mismatch raises an + SDK error with ``400 workspace_scope_mismatch`` before any write. + Returns ``201 Created`` with the persisted :class:`WorkspaceSpec`. """ body = await self._request( "POST", diff --git a/src/flycanon/web/controllers/workspaces_controller.py b/src/flycanon/web/controllers/workspaces_controller.py index ed05d33..c58c35c 100644 --- a/src/flycanon/web/controllers/workspaces_controller.py +++ b/src/flycanon/web/controllers/workspaces_controller.py @@ -1,4 +1,5 @@ # Copyright 2024-2026 Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -21,11 +22,10 @@ Headers: * ``X-Tenant-Id`` is the tenant scope. -* ``X-Workspace-Id`` is the caller's "current" workspace -- still - required by :func:`tenant_context_from_request` for header - uniformity, but the path ``{workspace_id}`` is the authoritative - identifier for GET / PATCH / close. ``POST`` creates a row whose - id is taken from the body. +* ``X-Workspace-Id`` is the request's workspace scope. Creation + requires the body ``id`` to match this header before storage or + event publication. Production RLS also restricts row visibility + to this scope; the path identifies the target for GET / PATCH / close. Path conventions: @@ -101,12 +101,16 @@ async def create( The body carries the caller-chosen workspace id (slug, ``<= 64`` chars). Tenant id comes from the ``X-Tenant-Id`` - header -- the body intentionally does not accept it so a - caller cannot create workspaces under another tenant. + header. The body id must equal ``X-Workspace-Id``; a mismatch + returns ``400 workspace_scope_mismatch`` before any write. Returns ``201 Created`` with the persisted :class:`WorkspaceSpec`. """ ctx: TenantContext = tenant_context_from_request(http_request) + if body.id != ctx.workspace_id: + raise WorkspaceScopeMismatch( + f"X-Workspace-Id {ctx.workspace_id!r} must equal the body workspace {body.id!r}." + ) row: dict[str, Any] = { "id": body.id, "tenant_id": ctx.tenant_id, diff --git a/src/flycanon/web/conventions/exceptions.py b/src/flycanon/web/conventions/exceptions.py index 9f367a7..23ee0fe 100644 --- a/src/flycanon/web/conventions/exceptions.py +++ b/src/flycanon/web/conventions/exceptions.py @@ -1,4 +1,5 @@ # Copyright 2024-2026 Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -123,9 +124,10 @@ class CallbackUrlNotAllowed(FireflyHTTPException): class WorkspaceScopeMismatch(FireflyHTTPException): - """The path ``{workspace_id}`` and ``X-Workspace-Id`` disagree. + """The target workspace and ``X-Workspace-Id`` disagree. - Raised by the destructive workspace verbs (``:purge``). Under the + Creation checks the body ``id`` before any storage or event write. + Purge checks the path ``{workspace_id}``. Under the production RLS role every row visible to the request is already filtered by the header workspace, so a purge addressed to a different path id would silently touch nothing -- or, under a diff --git a/tests/unit/test_workspaces_controller.py b/tests/unit/test_workspaces_controller.py index b0fb717..f9fcbd0 100644 --- a/tests/unit/test_workspaces_controller.py +++ b/tests/unit/test_workspaces_controller.py @@ -1,4 +1,5 @@ # Copyright 2024-2026 Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""End-to-end coverage for the ``/api/v1/workspaces`` controller. +"""Controller coverage for the ``/api/v1/workspaces`` surface. Exercises the controller methods directly with a stubbed Starlette :class:`Request` so the full handler chain (TenantContext extraction @@ -188,6 +189,31 @@ def _request(tenant_id: str = "acme", workspace_id: str = "default") -> object: class TestCreate: + @pytest.mark.asyncio + async def test_create_rejects_header_body_scope_before_storage_or_events( + self, controller: WorkspacesController, monkeypatch: pytest.MonkeyPatch + ) -> None: + from unittest.mock import AsyncMock + + from flycanon.web.conventions import WorkspaceScopeMismatch + + insert = AsyncMock(wraps=controller._repository.insert) + get = AsyncMock(wraps=controller._repository.get) + publish = AsyncMock(wraps=controller._event_publisher.publish_created) + monkeypatch.setattr(controller._repository, "insert", insert) + monkeypatch.setattr(controller._repository, "get", get) + monkeypatch.setattr(controller._event_publisher, "publish_created", publish) + with pytest.raises(WorkspaceScopeMismatch) as exc_info: + await controller.create( + _request(tenant_id="acme", workspace_id="authorized-workspace"), + WorkspaceCreate(id="different-workspace", name="Fictional scope fixture"), + ) + assert exc_info.value.status == 400 + assert exc_info.value.code == "workspace_scope_mismatch" + insert.assert_not_awaited() + get.assert_not_awaited() + publish.assert_not_awaited() + @pytest.mark.asyncio async def test_post_creates_and_returns_spec(self, controller: WorkspacesController) -> None: body = WorkspaceCreate( @@ -195,7 +221,7 @@ async def test_post_creates_and_returns_spec(self, controller: WorkspacesControl name="Q3 audit", status=WorkspaceStatus.active, ) - spec = await controller.create(_request(tenant_id="acme"), body) + spec = await controller.create(_request(tenant_id="acme", workspace_id="ws-1"), body) assert isinstance(spec, WorkspaceSpec) assert spec.id == "ws-1" assert spec.tenant_id == "acme" @@ -208,20 +234,20 @@ async def test_post_creates_and_returns_spec(self, controller: WorkspacesControl @pytest.mark.asyncio async def test_duplicate_id_raises_integrity_error(self, controller: WorkspacesController) -> None: body = WorkspaceCreate(id="ws-dup", name="A", status=WorkspaceStatus.active) - await controller.create(_request(tenant_id="acme"), body) + await controller.create(_request(tenant_id="acme", workspace_id="ws-dup"), body) with pytest.raises(IntegrityError): - await controller.create(_request(tenant_id="acme"), body) + await controller.create(_request(tenant_id="acme", workspace_id="ws-dup"), body) class TestList: @pytest.mark.asyncio async def test_list_returns_tenant_workspaces(self, controller: WorkspacesController) -> None: await controller.create( - _request(tenant_id="acme"), + _request(tenant_id="acme", workspace_id="ws-acme-1"), WorkspaceCreate(id="ws-acme-1", name="A", status=WorkspaceStatus.active), ) await controller.create( - _request(tenant_id="acme"), + _request(tenant_id="acme", workspace_id="ws-acme-2"), WorkspaceCreate(id="ws-acme-2", name="B", status=WorkspaceStatus.active), ) # Different tenant row -- must NOT surface in acme's list. The @@ -229,7 +255,7 @@ async def test_list_returns_tenant_workspaces(self, controller: WorkspacesContro # distinct id; the partitioning happens at the tenant_id WHERE # clause in :meth:`list_for_tenant`, not at id collision. await controller.create( - _request(tenant_id="bcorp"), + _request(tenant_id="bcorp", workspace_id="ws-bcorp-1"), WorkspaceCreate(id="ws-bcorp-1", name="cross-tenant", status=WorkspaceStatus.active), ) rows = await controller.list_workspaces(_request(tenant_id="acme")) @@ -244,7 +270,7 @@ class TestGet: @pytest.mark.asyncio async def test_get_returns_spec(self, controller: WorkspacesController) -> None: await controller.create( - _request(tenant_id="acme"), + _request(tenant_id="acme", workspace_id="ws-1"), WorkspaceCreate(id="ws-1", name="A", status=WorkspaceStatus.active), ) spec = await controller.get(_request(tenant_id="acme"), "ws-1") @@ -261,7 +287,7 @@ async def test_get_unknown_raises_404(self, controller: WorkspacesController) -> async def test_get_cross_tenant_raises_404(self, controller: WorkspacesController) -> None: # Workspace lives under tenant=acme; bcorp must not see it. await controller.create( - _request(tenant_id="acme"), + _request(tenant_id="acme", workspace_id="ws-1"), WorkspaceCreate(id="ws-1", name="A", status=WorkspaceStatus.active), ) with pytest.raises(WorkspaceNotFound): @@ -272,7 +298,7 @@ class TestUpdate: @pytest.mark.asyncio async def test_patch_applies_sparse_update(self, controller: WorkspacesController) -> None: await controller.create( - _request(tenant_id="acme"), + _request(tenant_id="acme", workspace_id="ws-1"), WorkspaceCreate(id="ws-1", name="Original", status=WorkspaceStatus.active), ) updated = await controller.update( @@ -300,7 +326,7 @@ async def test_close_transitions_status_and_stamps_closed_at( self, controller: WorkspacesController ) -> None: await controller.create( - _request(tenant_id="acme"), + _request(tenant_id="acme", workspace_id="ws-1"), WorkspaceCreate(id="ws-1", name="A", status=WorkspaceStatus.active), ) closed = await controller.close(_request(tenant_id="acme"), "ws-1") @@ -330,7 +356,7 @@ async def test_post_emits_workspace_created( captured_events: list[WorkspaceEventBase], ) -> None: await controller.create( - _request(tenant_id="acme"), + _request(tenant_id="acme", workspace_id="ws-1"), WorkspaceCreate(id="ws-1", name="Q3 audit", status=WorkspaceStatus.active), ) assert len(captured_events) == 1 @@ -349,7 +375,7 @@ async def test_patch_emits_workspace_updated( captured_events: list[WorkspaceEventBase], ) -> None: await controller.create( - _request(tenant_id="acme"), + _request(tenant_id="acme", workspace_id="ws-1"), WorkspaceCreate(id="ws-1", name="Original", status=WorkspaceStatus.active), ) captured_events.clear() # drop the WorkspaceCreated from create() @@ -373,7 +399,7 @@ async def test_close_emits_workspace_deleted( captured_events: list[WorkspaceEventBase], ) -> None: await controller.create( - _request(tenant_id="acme"), + _request(tenant_id="acme", workspace_id="ws-1"), WorkspaceCreate(id="ws-1", name="A", status=WorkspaceStatus.active), ) captured_events.clear() @@ -398,10 +424,10 @@ async def test_failed_create_publishes_no_event( not see a phantom WorkspaceCreated. """ body = WorkspaceCreate(id="ws-1", name="A", status=WorkspaceStatus.active) - await controller.create(_request(tenant_id="acme"), body) + await controller.create(_request(tenant_id="acme", workspace_id="ws-1"), body) captured_events.clear() with pytest.raises(IntegrityError): - await controller.create(_request(tenant_id="acme"), body) + await controller.create(_request(tenant_id="acme", workspace_id="ws-1"), body) assert captured_events == [] @pytest.mark.asyncio From 4ac738b37c37051c6b6d2cde39a1e2351a0377f2 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 14 Oct 2026 16:35:49 -0700 Subject: [PATCH 2/3] Describe actual workspace list and not-found behavior --- docs/api-reference.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index ba4a6f4..d63c97e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -174,16 +174,18 @@ in those headers; the tenant-wide list exception is described below. | Method | Path | Description | |--------|------|-------------| | `POST` | `/api/v1/workspaces` | Create. Body: `WorkspaceCreate`; `id` must match `X-Workspace-Id`. Returns `201` with `WorkspaceSpec`. | -| `GET` | `/api/v1/workspaces` | List within tenant. Query: `status`, `limit`, `offset`. | -| `GET` | `/api/v1/workspaces/{id}` | Fetch one. 404 → `resource_not_found`. | +| `GET` | `/api/v1/workspaces` | List visible workspaces within the tenant, newest first. No filter or pagination arguments. | +| `GET` | `/api/v1/workspaces/{id}` | Fetch one. 404 → `workspace_not_found`. | | `PATCH` | `/api/v1/workspaces/{id}` | Sparse update. Body: `WorkspaceUpdate`. | | `POST` | `/api/v1/workspaces/{id}:close` | Close (idempotent; sets `status=closed` + `closed_at`). Data stays. | | `POST` | `/api/v1/workspaces/{id}:purge` | Erase everything the workspace holds, then close it: every source through the full removal pipeline (vectors, chunks, stored originals, audit + event each), then knowledge items / versions / citations / relations, candidates, conversations / turns, ingest jobs / events and cost events in one transaction; emits `WorkspaceDeleted`; audit rows are kept and a `workspace.purged` row records the counts. `X-Workspace-Id` must equal the path id (`400 workspace_scope_mismatch`). Returns `WorkspacePurgeResult` -- every counter is what this call erased, `originals_deleted` only objects actually found in the store, `closed` only when this call closed the row; a repeat is all zeros, `closed: false`, no event, no audit row. (26.7.1) | -`GET /api/v1/workspaces` is a tenant-wide read and runs on the -BYPASSRLS engine named by `FLYCANON_ADMIN_DATABASE_URL`; under the -production `flycanon_app` role without that setting it can only return -the workspace named in the caller's own `X-Workspace-Id` header. +With a separate BYPASSRLS connection configured through +`FLYCANON_ADMIN_DATABASE_URL`, `GET /api/v1/workspaces` lists the +tenant's workspaces on that connection. Otherwise it uses the request +connection; under the production non-bypass role, RLS limits the +result to the workspace named in `X-Workspace-Id`. Both tenant and +workspace headers remain required in either configuration. ## Billing From 2352d70b39cbab7abc3581cfa294775e9af7acf7 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 14 Oct 2026 16:38:40 -0700 Subject: [PATCH 3/3] Release workspace scope validation as 26.10.4 --- CHANGELOG.md | 12 ++++++++++++ README.md | 2 +- docs/deployment.md | 6 +++--- docs/released-server-install.md | 14 +++++++------- openapi.json | 2 +- pyproject.toml | 2 +- sdks/java/README.md | 6 +++--- sdks/java/pom.xml | 2 +- .../java/com/firefly/flycanon/sdk/CanonClient.java | 2 +- sdks/python/README.md | 6 +++--- sdks/python/pyproject.toml | 2 +- sdks/python/src/flycanon_sdk/__init__.py | 2 +- sdks/python/tests/test_client.py | 4 ++-- sdks/python/uv.lock | 2 +- uv.lock | 2 +- 15 files changed, 39 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c2e770..c6f108c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to **flycanon** are documented here. +## [26.10.4] - 2026-10-14 + +### Fixed + +- Reject workspace creation when the body `id` differs from `X-Workspace-Id`. The service returns `400 workspace_scope_mismatch` before any storage or lifecycle event write, rather than exposing a PostgreSQL RLS failure as HTTP 500. Matching requests retain their existing `201` response. +- Correct the workspace reference: lookup returns `workspace_not_found`, and the list endpoint accepts no filter or pagination arguments. A separate configured admin connection permits tenant-wide listing; otherwise the request connection's RLS scope controls visibility. + +### Distribution + +- Align the server and Python/Java SDK versions at `26.10.4`. SDK methods, wire schemas, dependency pins and RLS policies are unchanged. Configure the SDK's workspace header to match the workspace being created; the published Python SDK `26.10.3` remains compatible with the corrected server. +- Native local checks cover creation, RFC 7807 errors, SDK propagation, foreign-scope visibility and credential denial. Lifecycle event delivery remains best-effort after a successful workspace write; this patch adds no delivery guarantee or tenant-bound platform-key grant. + ## [26.10.3] - 2026-10-14 ### Fixed diff --git a/README.md b/README.md index e3b03d5..866b8d4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ over whole documents with citations — all behind a single HTTP service. [![agentic](https://img.shields.io/badge/genai-fireflyframework--agentic-purple)](https://github.com/fireflyframework/fireflyframework-agentic) [![OpenAPI](https://img.shields.io/badge/api-openapi%203.1-green)](docs/api-reference.md) [![vector store](https://img.shields.io/badge/vector--store-pgvector%20%7C%20qdrant%20%7C%20chroma-336791)](docs/architecture.md#retrieval-backend-pluggable-dense-store) -[![Version](https://img.shields.io/badge/version-26.10.3-green.svg)](#) +[![Version](https://img.shields.io/badge/version-26.10.4-green.svg)](#) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) diff --git a/docs/deployment.md b/docs/deployment.md index ff08191..3784612 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -43,7 +43,7 @@ For a host installation using the released wheel, follow [Install the released s ```bash docker pull ghcr.io/fireflyframework/flycanon:latest # latest main -docker pull ghcr.io/fireflyframework/flycanon:26.10.3 # specific release +docker pull ghcr.io/fireflyframework/flycanon:26.10.4 # specific release docker pull ghcr.io/fireflyframework/flycanon:26.10 # latest 26.10.x ``` @@ -681,12 +681,12 @@ The recommended upgrade dance: ```bash # 1. Pull the new image -docker pull ghcr.io/fireflyframework/flycanon:26.10.3 +docker pull ghcr.io/fireflyframework/flycanon:26.10.4 # 2. Run migrations against the live database docker run --rm \ --env-file .env \ - ghcr.io/fireflyframework/flycanon:26.10.3 migrate + ghcr.io/fireflyframework/flycanon:26.10.4 migrate # 3. Roll the API + worker deployments docker compose --env-file .env up -d diff --git a/docs/released-server-install.md b/docs/released-server-install.md index 91f04db..f42155a 100644 --- a/docs/released-server-install.md +++ b/docs/released-server-install.md @@ -6,13 +6,13 @@ Use the released source archive for the operator configuration and migration fil ## Download and verify -Download `flycanon-26.10.3-py3-none-any.whl`, `flycanon-26.10.3.tar.gz`, `runtime-requirements-26.10.3.txt`, and `SHA256SUMS` from the [26.10.3 release](https://github.com/fireflyframework/flycanon/releases/tag/v26.10.3). Verify the downloaded files against their matching checksum entries before extracting or installing them. +Download `flycanon-26.10.4-py3-none-any.whl`, `flycanon-26.10.4.tar.gz`, `runtime-requirements-26.10.4.txt`, and `SHA256SUMS` from the [26.10.4 release](https://github.com/fireflyframework/flycanon/releases/tag/v26.10.4). Verify the downloaded files against their matching checksum entries before extracting or installing them. ```bash -tar -xzf flycanon-26.10.3.tar.gz -cd flycanon-26.10.3 +tar -xzf flycanon-26.10.4.tar.gz +cd flycanon-26.10.4 uv venv --python 3.13 .venv -uv pip install --python .venv/bin/python -r ../runtime-requirements-26.10.3.txt +uv pip install --python .venv/bin/python -r ../runtime-requirements-26.10.4.txt ``` The requirements file selects the released server wheel and these canonical framework references: @@ -26,7 +26,7 @@ The equivalent installation without a requirements file is: ```bash uv pip install --python .venv/bin/python \ - 'flycanon @ https://github.com/fireflyframework/flycanon/releases/download/v26.10.3/flycanon-26.10.3-py3-none-any.whl' \ + 'flycanon @ https://github.com/fireflyframework/flycanon/releases/download/v26.10.4/flycanon-26.10.4-py3-none-any.whl' \ 'pyfly @ git+https://github.com/fireflyframework/fireflyframework-pyfly.git@v26.09.04' \ 'fireflyframework-agentic @ https://github.com/fireflyframework/fireflyframework-agentic/releases/download/v26.10.1/fireflyframework_agentic-26.10.1-py3-none-any.whl' ``` @@ -39,11 +39,11 @@ This release retains pyfly `v26.09.04` and the existing PostgreSQL EDA outbox. A ## Configure the runtime -Keep `pyfly.yaml`, `alembic.ini`, `migrations/`, and `env_template` from the source archive in the working directory. The wheel does not supply the root-level `pyfly.yaml`. The release also provides a byte-identical `pyfly-26.10.3.yaml` copy for operators who already have a migration scaffold; install it as `pyfly.yaml` in the runtime working directory. +Keep `pyfly.yaml`, `alembic.ini`, `migrations/`, and `env_template` from the source archive in the working directory. The wheel does not supply the root-level `pyfly.yaml`. The release also provides a byte-identical `pyfly-26.10.4.yaml` copy for operators who already have a migration scaffold; install it as `pyfly.yaml` in the runtime working directory. The configuration selects the PostgreSQL event adapter and separate API/worker consumer groups. Starting without it can activate framework defaults, including an attempted Kafka connection to `localhost:9092` and a separate management listener. Do not rely on those defaults for the released service. -Set the database URL, embedding model and dimensions, provider credentials, platform API keys, object-storage location, and optional Redis settings for your environment. Use [`env_template`](https://github.com/fireflyframework/flycanon/blob/v26.10.3/env_template) and the [deployment guide](https://github.com/fireflyframework/flycanon/blob/v26.10.3/docs/deployment.md) for the exact settings. Store real secrets outside published configuration and supply them through your operator's environment or secret manager. +Set the database URL, embedding model and dimensions, provider credentials, platform API keys, object-storage location, and optional Redis settings for your environment. Use [`env_template`](https://github.com/fireflyframework/flycanon/blob/v26.10.4/env_template) and the [deployment guide](https://github.com/fireflyframework/flycanon/blob/v26.10.4/docs/deployment.md) for the exact settings. Store real secrets outside published configuration and supply them through your operator's environment or secret manager. Provision the PostgreSQL database, required extensions and roles using the deployment guide. Apply migrations as a separate operator step before serving a new database: diff --git a/openapi.json b/openapi.json index b62190b..b9caf24 100644 --- a/openapi.json +++ b/openapi.json @@ -5103,7 +5103,7 @@ "url": "https://github.com/fireflyframework/flycanon/blob/main/LICENSE" }, "title": "flycanon", - "version": "26.10.3" + "version": "26.10.4" }, "openapi": "3.1.0", "paths": { diff --git a/pyproject.toml b/pyproject.toml index 4fd6f68..96a44b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ name = "flycanon" # CalVer YY.MM.PP -- bumped per release. Note that PEP 440 normalises # ``26.05.04`` -> ``26.5.4`` in the built wheel filename. -version = "26.10.3" +version = "26.10.4" description = "Operational Knowledge Repository -- versioned, provenance-tracked canonical knowledge with hybrid retrieval and RAG, exposed as a standalone HTTP microservice over fireflyframework-pyfly and -agentic. Part of Firefly Framework, platform-agnostic by design." readme = "README.md" requires-python = ">=3.13,<3.14" diff --git a/sdks/java/README.md b/sdks/java/README.md index a9f11b9..9bb3deb 100644 --- a/sdks/java/README.md +++ b/sdks/java/README.md @@ -41,7 +41,7 @@ Compatible with **flycanon service version `26.10.x`**. | SDK | Service | |-----|---------| -| `26.10.3` | `26.10.x` (unchanged HTTP contract; Agentic dependency adoption) | +| `26.10.4` | `26.10.x` (same request/response schemas; workspace creation requires matching scope) | | `26.5.7` | `26.5.x` | ## Install @@ -50,7 +50,7 @@ Compatible with **flycanon service version `26.10.x`**. com.firefly flycanon-sdk - 26.10.3 + 26.10.4 ``` @@ -418,7 +418,7 @@ use). ## Versioning The SDK pins its version to the service's CalVer (`YY.MM.PP`), so -`flycanon-sdk@26.10.3` is the matching client for service version +`flycanon-sdk@26.10.4` is the matching client for service version `26.10.x`. Upgrade the SDK in lockstep with the service. Both clients (`CanonClient`, `ReactiveCanonClient`) ship from the same artifact -- no separate dependency to manage. diff --git a/sdks/java/pom.xml b/sdks/java/pom.xml index 96c2c13..48e0cc8 100644 --- a/sdks/java/pom.xml +++ b/sdks/java/pom.xml @@ -19,7 +19,7 @@ com.firefly flycanon-sdk - 26.10.3 + 26.10.4 jar flycanon-sdk diff --git a/sdks/java/src/main/java/com/firefly/flycanon/sdk/CanonClient.java b/sdks/java/src/main/java/com/firefly/flycanon/sdk/CanonClient.java index 7204c9c..ba33efd 100644 --- a/sdks/java/src/main/java/com/firefly/flycanon/sdk/CanonClient.java +++ b/sdks/java/src/main/java/com/firefly/flycanon/sdk/CanonClient.java @@ -94,7 +94,7 @@ public final class CanonClient { static final String HEADER_IDEMPOTENCY_KEY = "Idempotency-Key"; /** SDK version. Mirrors {@code pom.xml}. */ - public static final String SDK_VERSION = "26.10.3"; + public static final String SDK_VERSION = "26.10.4"; private final RestClient restClient; private final ObjectMapper mapper; diff --git a/sdks/python/README.md b/sdks/python/README.md index f98b5aa..1ca31b2 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -33,7 +33,7 @@ Compatible with **flycanon service version `26.10.x`**. | SDK | Service | |-----|---------| -| `26.10.3` | `26.10.x` (unchanged HTTP contract; Agentic dependency adoption) | +| `26.10.4` | `26.10.x` (same request/response schemas; workspace creation requires matching scope) | | `26.8.0` | `26.8.x` (embedding sets, `flycanon reindex`, `ChunkStats.by_embedding_model`) | | `26.7.1` | `26.7.x` (routes realigned: `?mode=async`, `/query/stream`, `/conversations/{id}/turn`, `/query/suggest`, `?after_id=`; `X-API-Key`; `delete_source`, `purge_workspace`) | | `26.5.7` | `26.5.x` | @@ -41,9 +41,9 @@ Compatible with **flycanon service version `26.10.x`**. ## Install ```bash -uv add https://github.com/fireflyframework/flycanon/releases/download/v26.10.3/flycanon_sdk-26.10.3-py3-none-any.whl +uv add https://github.com/fireflyframework/flycanon/releases/download/v26.10.4/flycanon_sdk-26.10.4-py3-none-any.whl # or -pip install https://github.com/fireflyframework/flycanon/releases/download/v26.10.3/flycanon_sdk-26.10.3-py3-none-any.whl +pip install https://github.com/fireflyframework/flycanon/releases/download/v26.10.4/flycanon_sdk-26.10.4-py3-none-any.whl ``` ## Quick start diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 8dd20d6..2d446c9 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 [project] name = "flycanon-sdk" -version = "26.10.3" +version = "26.10.4" description = "Async-first Python client for the flycanon Operational Knowledge Repository service." readme = "README.md" requires-python = ">=3.11" diff --git a/sdks/python/src/flycanon_sdk/__init__.py b/sdks/python/src/flycanon_sdk/__init__.py index 62bdfab..7cef567 100644 --- a/sdks/python/src/flycanon_sdk/__init__.py +++ b/sdks/python/src/flycanon_sdk/__init__.py @@ -120,7 +120,7 @@ WorkspaceUpdated, ) -__version__ = "26.10.3" +__version__ = "26.10.4" __all__ = [ "CANON_WORKSPACES_TOPIC", diff --git a/sdks/python/tests/test_client.py b/sdks/python/tests/test_client.py index b4d5a55..ecede67 100644 --- a/sdks/python/tests/test_client.py +++ b/sdks/python/tests/test_client.py @@ -592,7 +592,7 @@ async def test_answer_returns_typed_response() -> None: def test_sdk_version_declares_current_release() -> None: - assert __version__ == "26.10.3" + assert __version__ == "26.10.4" def test_canon_workspaces_topic_constant() -> None: @@ -674,7 +674,7 @@ async def test_user_agent_advertises_sdk_version() -> None: } ) await client.version() - assert "flycanon-sdk-python/26.10.3" in route.calls.last.request.headers["User-Agent"] + assert "flycanon-sdk-python/26.10.4" in route.calls.last.request.headers["User-Agent"] # ---------------------------------------------------------------------- diff --git a/sdks/python/uv.lock b/sdks/python/uv.lock index 3e5c318..65a238c 100644 --- a/sdks/python/uv.lock +++ b/sdks/python/uv.lock @@ -44,7 +44,7 @@ wheels = [ [[package]] name = "flycanon-sdk" -version = "26.10.3" +version = "26.10.4" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/uv.lock b/uv.lock index 085d655..762f686 100644 --- a/uv.lock +++ b/uv.lock @@ -1420,7 +1420,7 @@ wheels = [ [[package]] name = "flycanon" -version = "26.10.3" +version = "26.10.4" source = { editable = "." } dependencies = [ { name = "aiosqlite" },