From e703316414c0b5df25ef7a9917170a9a3476e6b1 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Sun, 9 Aug 2026 15:15:15 -0500 Subject: [PATCH 1/3] feat: cross-graph share controls on the ledger client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerates against the backend's new share-control surface and adds the LedgerClient facade methods for it: - block_source_graph / unblock_source_graph — the recipient's exit from cross-graph report sharing, with optional purge of what already landed - revoke_report_share — the sender's half, withdrawing a delivered copy - list_blocked_source_graphs — the GraphQL read, via a new ListLedgerBlockedSourceGraphs operation Also picks up two unrelated backend changes the regen swept in: the delete-report and share-report descriptions, and the removal of the API-call fields from OrgUsageSummary that could never be non-zero. All additive on the facade; the generated-tier removal on OrgUsageSummary is a response-model narrowing, not a facade change. --- .../block_source_graph.py | 270 ++++++++++++++++++ .../extensions_robo_ledger/delete_report.py | 20 +- .../revoke_report_share.py | 270 ++++++++++++++++++ .../extensions_robo_ledger/share_report.py | 16 +- .../unblock_source_graph.py | 264 +++++++++++++++++ robosystems_client/clients/ledger_client.py | 111 +++++++ .../graphql/generated/__init__.py | 12 + .../graphql/generated/client.py | 15 + .../list_ledger_blocked_source_graphs.py | 38 +++ .../graphql/generated/operations.py | 22 ++ .../ListLedgerBlockedSourceGraphs.graphql | 9 + robosystems_client/graphql/schema.graphql | 31 ++ robosystems_client/models/__init__.py | 36 +++ .../models/block_source_graph_operation.py | 95 ++++++ .../models/block_source_graph_result.py | 90 ++++++ .../models/blocked_source_graph_response.py | 129 +++++++++ ...tion_envelope_block_source_graph_result.py | 158 ++++++++++ ...velope_block_source_graph_result_status.py | 10 + ..._envelope_blocked_source_graph_response.py | 158 ++++++++++ ...pe_blocked_source_graph_response_status.py | 10 + ...n_envelope_revoke_report_share_response.py | 158 ++++++++++ ...ope_revoke_report_share_response_status.py | 10 + .../models/org_usage_summary.py | 38 --- .../models/revoke_report_share_operation.py | 70 +++++ .../models/revoke_report_share_response.py | 88 ++++++ .../models/unblock_source_graph_operation.py | 62 ++++ tests/test_ledger_client.py | 151 ++++++++++ 27 files changed, 2295 insertions(+), 46 deletions(-) create mode 100644 robosystems_client/api/extensions_robo_ledger/block_source_graph.py create mode 100644 robosystems_client/api/extensions_robo_ledger/revoke_report_share.py create mode 100644 robosystems_client/api/extensions_robo_ledger/unblock_source_graph.py create mode 100644 robosystems_client/graphql/generated/list_ledger_blocked_source_graphs.py create mode 100644 robosystems_client/graphql/operations/ledger/ListLedgerBlockedSourceGraphs.graphql create mode 100644 robosystems_client/models/block_source_graph_operation.py create mode 100644 robosystems_client/models/block_source_graph_result.py create mode 100644 robosystems_client/models/blocked_source_graph_response.py create mode 100644 robosystems_client/models/operation_envelope_block_source_graph_result.py create mode 100644 robosystems_client/models/operation_envelope_block_source_graph_result_status.py create mode 100644 robosystems_client/models/operation_envelope_blocked_source_graph_response.py create mode 100644 robosystems_client/models/operation_envelope_blocked_source_graph_response_status.py create mode 100644 robosystems_client/models/operation_envelope_revoke_report_share_response.py create mode 100644 robosystems_client/models/operation_envelope_revoke_report_share_response_status.py create mode 100644 robosystems_client/models/revoke_report_share_operation.py create mode 100644 robosystems_client/models/revoke_report_share_response.py create mode 100644 robosystems_client/models/unblock_source_graph_operation.py diff --git a/robosystems_client/api/extensions_robo_ledger/block_source_graph.py b/robosystems_client/api/extensions_robo_ledger/block_source_graph.py new file mode 100644 index 0000000..8d680af --- /dev/null +++ b/robosystems_client/api/extensions_robo_ledger/block_source_graph.py @@ -0,0 +1,270 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.block_source_graph_operation import BlockSourceGraphOperation +from ...models.error_response import ErrorResponse +from ...models.operation_envelope_block_source_graph_result import ( + OperationEnvelopeBlockSourceGraphResult, +) +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + graph_id: str, + *, + body: BlockSourceGraphOperation, + idempotency_key: None | str | Unset = UNSET, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + if not isinstance(idempotency_key, Unset): + headers["Idempotency-Key"] = idempotency_key + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/extensions/roboledger/{graph_id}/operations/block-source-graph".format( + graph_id=quote(str(graph_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorResponse | OperationEnvelopeBlockSourceGraphResult | None: + if response.status_code == 200: + response_200 = OperationEnvelopeBlockSourceGraphResult.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = ErrorResponse.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ErrorResponse.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = ErrorResponse.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ErrorResponse.from_dict(response.json()) + + return response_404 + + if response.status_code == 409: + response_409 = ErrorResponse.from_dict(response.json()) + + return response_409 + + if response.status_code == 422: + response_422 = ErrorResponse.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = ErrorResponse.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorResponse.from_dict(response.json()) + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorResponse | OperationEnvelopeBlockSourceGraphResult]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + body: BlockSourceGraphOperation, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeBlockSourceGraphResult]: + """Block Source Graph + + Bars a graph from sharing reports into this one. Subsequent `share-report` calls naming this graph + fail for this target with an explicit error — blocked senders are told, not silently dropped. + Idempotent: re-blocking preserves the original `blocked_at`. Set `purge` to also delete every report + already shared in from that source, along with its fact sets and facts; reports this graph authored + are never touched. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (BlockSourceGraphOperation): Bar a graph from sharing reports into this one. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | OperationEnvelopeBlockSourceGraphResult] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + body=body, + idempotency_key=idempotency_key, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + graph_id: str, + *, + client: AuthenticatedClient, + body: BlockSourceGraphOperation, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeBlockSourceGraphResult | None: + """Block Source Graph + + Bars a graph from sharing reports into this one. Subsequent `share-report` calls naming this graph + fail for this target with an explicit error — blocked senders are told, not silently dropped. + Idempotent: re-blocking preserves the original `blocked_at`. Set `purge` to also delete every report + already shared in from that source, along with its fact sets and facts; reports this graph authored + are never touched. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (BlockSourceGraphOperation): Bar a graph from sharing reports into this one. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | OperationEnvelopeBlockSourceGraphResult + """ + + return sync_detailed( + graph_id=graph_id, + client=client, + body=body, + idempotency_key=idempotency_key, + ).parsed + + +async def asyncio_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + body: BlockSourceGraphOperation, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeBlockSourceGraphResult]: + """Block Source Graph + + Bars a graph from sharing reports into this one. Subsequent `share-report` calls naming this graph + fail for this target with an explicit error — blocked senders are told, not silently dropped. + Idempotent: re-blocking preserves the original `blocked_at`. Set `purge` to also delete every report + already shared in from that source, along with its fact sets and facts; reports this graph authored + are never touched. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (BlockSourceGraphOperation): Bar a graph from sharing reports into this one. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | OperationEnvelopeBlockSourceGraphResult] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + body=body, + idempotency_key=idempotency_key, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + graph_id: str, + *, + client: AuthenticatedClient, + body: BlockSourceGraphOperation, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeBlockSourceGraphResult | None: + """Block Source Graph + + Bars a graph from sharing reports into this one. Subsequent `share-report` calls naming this graph + fail for this target with an explicit error — blocked senders are told, not silently dropped. + Idempotent: re-blocking preserves the original `blocked_at`. Set `purge` to also delete every report + already shared in from that source, along with its fact sets and facts; reports this graph authored + are never touched. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (BlockSourceGraphOperation): Bar a graph from sharing reports into this one. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | OperationEnvelopeBlockSourceGraphResult + """ + + return ( + await asyncio_detailed( + graph_id=graph_id, + client=client, + body=body, + idempotency_key=idempotency_key, + ) + ).parsed diff --git a/robosystems_client/api/extensions_robo_ledger/delete_report.py b/robosystems_client/api/extensions_robo_ledger/delete_report.py index 5fef234..7f5362e 100644 --- a/robosystems_client/api/extensions_robo_ledger/delete_report.py +++ b/robosystems_client/api/extensions_robo_ledger/delete_report.py @@ -111,7 +111,10 @@ def sync_detailed( ) -> Response[ErrorResponse | OperationEnvelopeDeleteResult]: """Delete Report - Deletes the report definition and all generated facts. + Deletes the report definition and all generated facts. Normally restricted to the report's creator. + A report shared in from another graph carries the sender's user id in `created_by`, so those may be + deleted by any admin of the receiving graph — the recipient's exit from an unsolicited share. + Deleting a shared copy does not affect the sender's record that they sent it. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -151,7 +154,10 @@ def sync( ) -> ErrorResponse | OperationEnvelopeDeleteResult | None: """Delete Report - Deletes the report definition and all generated facts. + Deletes the report definition and all generated facts. Normally restricted to the report's creator. + A report shared in from another graph carries the sender's user id in `created_by`, so those may be + deleted by any admin of the receiving graph — the recipient's exit from an unsolicited share. + Deleting a shared copy does not affect the sender's record that they sent it. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -186,7 +192,10 @@ async def asyncio_detailed( ) -> Response[ErrorResponse | OperationEnvelopeDeleteResult]: """Delete Report - Deletes the report definition and all generated facts. + Deletes the report definition and all generated facts. Normally restricted to the report's creator. + A report shared in from another graph carries the sender's user id in `created_by`, so those may be + deleted by any admin of the receiving graph — the recipient's exit from an unsolicited share. + Deleting a shared copy does not affect the sender's record that they sent it. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -224,7 +233,10 @@ async def asyncio( ) -> ErrorResponse | OperationEnvelopeDeleteResult | None: """Delete Report - Deletes the report definition and all generated facts. + Deletes the report definition and all generated facts. Normally restricted to the report's creator. + A report shared in from another graph carries the sender's user id in `created_by`, so those may be + deleted by any admin of the receiving graph — the recipient's exit from an unsolicited share. + Deleting a shared copy does not affect the sender's record that they sent it. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. diff --git a/robosystems_client/api/extensions_robo_ledger/revoke_report_share.py b/robosystems_client/api/extensions_robo_ledger/revoke_report_share.py new file mode 100644 index 0000000..9d46afb --- /dev/null +++ b/robosystems_client/api/extensions_robo_ledger/revoke_report_share.py @@ -0,0 +1,270 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...models.operation_envelope_revoke_report_share_response import ( + OperationEnvelopeRevokeReportShareResponse, +) +from ...models.revoke_report_share_operation import RevokeReportShareOperation +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + graph_id: str, + *, + body: RevokeReportShareOperation, + idempotency_key: None | str | Unset = UNSET, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + if not isinstance(idempotency_key, Unset): + headers["Idempotency-Key"] = idempotency_key + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/extensions/roboledger/{graph_id}/operations/revoke-report-share".format( + graph_id=quote(str(graph_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorResponse | OperationEnvelopeRevokeReportShareResponse | None: + if response.status_code == 200: + response_200 = OperationEnvelopeRevokeReportShareResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = ErrorResponse.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ErrorResponse.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = ErrorResponse.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ErrorResponse.from_dict(response.json()) + + return response_404 + + if response.status_code == 409: + response_409 = ErrorResponse.from_dict(response.json()) + + return response_409 + + if response.status_code == 422: + response_422 = ErrorResponse.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = ErrorResponse.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorResponse.from_dict(response.json()) + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorResponse | OperationEnvelopeRevokeReportShareResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + body: RevokeReportShareOperation, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeRevokeReportShareResponse]: + """Revoke Report Share + + Withdraws a report previously shared to one recipient graph: deletes the copy from that recipient's + schema and stamps the share record revoked. Scoped to a single recipient — withdrawing a + distribution to a whole publish list is one call per member. A recipient who already deleted the + copy is not an error; the share is still marked revoked and `copy_deleted` returns false. The linked + entity in the recipient's graph is left in place, so an investor's declared holding survives. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (RevokeReportShareOperation): Withdraw a shared Report from one recipient graph. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | OperationEnvelopeRevokeReportShareResponse] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + body=body, + idempotency_key=idempotency_key, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + graph_id: str, + *, + client: AuthenticatedClient, + body: RevokeReportShareOperation, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeRevokeReportShareResponse | None: + """Revoke Report Share + + Withdraws a report previously shared to one recipient graph: deletes the copy from that recipient's + schema and stamps the share record revoked. Scoped to a single recipient — withdrawing a + distribution to a whole publish list is one call per member. A recipient who already deleted the + copy is not an error; the share is still marked revoked and `copy_deleted` returns false. The linked + entity in the recipient's graph is left in place, so an investor's declared holding survives. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (RevokeReportShareOperation): Withdraw a shared Report from one recipient graph. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | OperationEnvelopeRevokeReportShareResponse + """ + + return sync_detailed( + graph_id=graph_id, + client=client, + body=body, + idempotency_key=idempotency_key, + ).parsed + + +async def asyncio_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + body: RevokeReportShareOperation, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeRevokeReportShareResponse]: + """Revoke Report Share + + Withdraws a report previously shared to one recipient graph: deletes the copy from that recipient's + schema and stamps the share record revoked. Scoped to a single recipient — withdrawing a + distribution to a whole publish list is one call per member. A recipient who already deleted the + copy is not an error; the share is still marked revoked and `copy_deleted` returns false. The linked + entity in the recipient's graph is left in place, so an investor's declared holding survives. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (RevokeReportShareOperation): Withdraw a shared Report from one recipient graph. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | OperationEnvelopeRevokeReportShareResponse] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + body=body, + idempotency_key=idempotency_key, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + graph_id: str, + *, + client: AuthenticatedClient, + body: RevokeReportShareOperation, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeRevokeReportShareResponse | None: + """Revoke Report Share + + Withdraws a report previously shared to one recipient graph: deletes the copy from that recipient's + schema and stamps the share record revoked. Scoped to a single recipient — withdrawing a + distribution to a whole publish list is one call per member. A recipient who already deleted the + copy is not an error; the share is still marked revoked and `copy_deleted` returns false. The linked + entity in the recipient's graph is left in place, so an investor's declared holding survives. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (RevokeReportShareOperation): Withdraw a shared Report from one recipient graph. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | OperationEnvelopeRevokeReportShareResponse + """ + + return ( + await asyncio_detailed( + graph_id=graph_id, + client=client, + body=body, + idempotency_key=idempotency_key, + ) + ).parsed diff --git a/robosystems_client/api/extensions_robo_ledger/share_report.py b/robosystems_client/api/extensions_robo_ledger/share_report.py index 41a5833..b8d977a 100644 --- a/robosystems_client/api/extensions_robo_ledger/share_report.py +++ b/robosystems_client/api/extensions_robo_ledger/share_report.py @@ -116,7 +116,9 @@ def sync_detailed( Pushes a published report to every member of the target publish list. Each share is an independent copy: the report row + all its facts are cloned into the recipient's tenant schema with `source_graph_id` / `source_report_id` provenance fields populated. Per-target outcomes (success or - error) surface in the response — share does not fail-fast across targets. + error) surface in the response — share does not fail-fast across targets. Recipients that have + blocked this graph come back as an error for that target; withdraw a delivered copy with `revoke- + report-share`. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -159,7 +161,9 @@ def sync( Pushes a published report to every member of the target publish list. Each share is an independent copy: the report row + all its facts are cloned into the recipient's tenant schema with `source_graph_id` / `source_report_id` provenance fields populated. Per-target outcomes (success or - error) surface in the response — share does not fail-fast across targets. + error) surface in the response — share does not fail-fast across targets. Recipients that have + blocked this graph come back as an error for that target; withdraw a delivered copy with `revoke- + report-share`. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -197,7 +201,9 @@ async def asyncio_detailed( Pushes a published report to every member of the target publish list. Each share is an independent copy: the report row + all its facts are cloned into the recipient's tenant schema with `source_graph_id` / `source_report_id` provenance fields populated. Per-target outcomes (success or - error) surface in the response — share does not fail-fast across targets. + error) surface in the response — share does not fail-fast across targets. Recipients that have + blocked this graph come back as an error for that target; withdraw a delivered copy with `revoke- + report-share`. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -238,7 +244,9 @@ async def asyncio( Pushes a published report to every member of the target publish list. Each share is an independent copy: the report row + all its facts are cloned into the recipient's tenant schema with `source_graph_id` / `source_report_id` provenance fields populated. Per-target outcomes (success or - error) surface in the response — share does not fail-fast across targets. + error) surface in the response — share does not fail-fast across targets. Recipients that have + blocked this graph come back as an error for that target; withdraw a delivered copy with `revoke- + report-share`. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. diff --git a/robosystems_client/api/extensions_robo_ledger/unblock_source_graph.py b/robosystems_client/api/extensions_robo_ledger/unblock_source_graph.py new file mode 100644 index 0000000..ee215e1 --- /dev/null +++ b/robosystems_client/api/extensions_robo_ledger/unblock_source_graph.py @@ -0,0 +1,264 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...models.operation_envelope_blocked_source_graph_response import ( + OperationEnvelopeBlockedSourceGraphResponse, +) +from ...models.unblock_source_graph_operation import UnblockSourceGraphOperation +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + graph_id: str, + *, + body: UnblockSourceGraphOperation, + idempotency_key: None | str | Unset = UNSET, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + if not isinstance(idempotency_key, Unset): + headers["Idempotency-Key"] = idempotency_key + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/extensions/roboledger/{graph_id}/operations/unblock-source-graph".format( + graph_id=quote(str(graph_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorResponse | OperationEnvelopeBlockedSourceGraphResponse | None: + if response.status_code == 200: + response_200 = OperationEnvelopeBlockedSourceGraphResponse.from_dict( + response.json() + ) + + return response_200 + + if response.status_code == 400: + response_400 = ErrorResponse.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ErrorResponse.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = ErrorResponse.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ErrorResponse.from_dict(response.json()) + + return response_404 + + if response.status_code == 409: + response_409 = ErrorResponse.from_dict(response.json()) + + return response_409 + + if response.status_code == 422: + response_422 = ErrorResponse.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = ErrorResponse.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorResponse.from_dict(response.json()) + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorResponse | OperationEnvelopeBlockedSourceGraphResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + body: UnblockSourceGraphOperation, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeBlockedSourceGraphResponse]: + """Unblock Source Graph + + Lifts a block, allowing that graph to share reports into this one again. Reports removed by an + earlier purge are not restored — unblocking reopens the channel, it does not undo. Returns 404 when + the source was not blocked. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (UnblockSourceGraphOperation): Lift a block on a source graph. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | OperationEnvelopeBlockedSourceGraphResponse] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + body=body, + idempotency_key=idempotency_key, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + graph_id: str, + *, + client: AuthenticatedClient, + body: UnblockSourceGraphOperation, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeBlockedSourceGraphResponse | None: + """Unblock Source Graph + + Lifts a block, allowing that graph to share reports into this one again. Reports removed by an + earlier purge are not restored — unblocking reopens the channel, it does not undo. Returns 404 when + the source was not blocked. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (UnblockSourceGraphOperation): Lift a block on a source graph. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | OperationEnvelopeBlockedSourceGraphResponse + """ + + return sync_detailed( + graph_id=graph_id, + client=client, + body=body, + idempotency_key=idempotency_key, + ).parsed + + +async def asyncio_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + body: UnblockSourceGraphOperation, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeBlockedSourceGraphResponse]: + """Unblock Source Graph + + Lifts a block, allowing that graph to share reports into this one again. Reports removed by an + earlier purge are not restored — unblocking reopens the channel, it does not undo. Returns 404 when + the source was not blocked. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (UnblockSourceGraphOperation): Lift a block on a source graph. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | OperationEnvelopeBlockedSourceGraphResponse] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + body=body, + idempotency_key=idempotency_key, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + graph_id: str, + *, + client: AuthenticatedClient, + body: UnblockSourceGraphOperation, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeBlockedSourceGraphResponse | None: + """Unblock Source Graph + + Lifts a block, allowing that graph to share reports into this one again. Reports removed by an + earlier purge are not restored — unblocking reopens the channel, it does not undo. Returns 404 when + the source was not blocked. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (UnblockSourceGraphOperation): Lift a block on a source graph. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | OperationEnvelopeBlockedSourceGraphResponse + """ + + return ( + await asyncio_detailed( + graph_id=graph_id, + client=client, + body=body, + idempotency_key=idempotency_key, + ) + ).parsed diff --git a/robosystems_client/clients/ledger_client.py b/robosystems_client/clients/ledger_client.py index da0d408..66b1b6a 100644 --- a/robosystems_client/clients/ledger_client.py +++ b/robosystems_client/clients/ledger_client.py @@ -115,6 +115,9 @@ from ..api.extensions_robo_ledger.add_publish_list_members import ( sync_detailed as op_add_publish_list_members, ) +from ..api.extensions_robo_ledger.block_source_graph import ( + sync_detailed as op_block_source_graph, +) from ..api.extensions_robo_ledger.create_publish_list import ( sync_detailed as op_create_publish_list, ) @@ -136,12 +139,18 @@ from ..api.extensions_robo_ledger.remove_publish_list_member import ( sync_detailed as op_remove_publish_list_member, ) +from ..api.extensions_robo_ledger.revoke_report_share import ( + sync_detailed as op_revoke_report_share, +) from ..api.extensions_robo_ledger.share_report import ( sync_detailed as op_share_report, ) from ..api.extensions_robo_ledger.transition_filing_status import ( sync_detailed as op_transition_filing_status, ) +from ..api.extensions_robo_ledger.unblock_source_graph import ( + sync_detailed as op_unblock_source_graph, +) from ..api.extensions_robo_ledger.update_publish_list import ( sync_detailed as op_update_publish_list, ) @@ -299,6 +308,12 @@ from ..graphql.generated.list_ledger_accounts import ( ListLedgerAccountsAccounts as LedgerAccountsPage, ) +from ..graphql.generated.list_ledger_blocked_source_graphs import ( + ListLedgerBlockedSourceGraphs, +) +from ..graphql.generated.list_ledger_blocked_source_graphs import ( + ListLedgerBlockedSourceGraphsBlockedSourceGraphs as BlockedSourceGraphsPage, +) from ..graphql.generated.list_ledger_agents import ( ListLedgerAgents, ListLedgerAgentsAgents, @@ -378,6 +393,7 @@ GET_LEDGER_TRIAL_BALANCE_GQL, LIST_INFORMATION_BLOCKS_GQL, LIST_LEDGER_ACCOUNTS_GQL, + LIST_LEDGER_BLOCKED_SOURCE_GRAPHS_GQL, LIST_LEDGER_AGENTS_GQL, LIST_LEDGER_ELEMENTS_GQL, LIST_LEDGER_ENTITIES_GQL, @@ -455,6 +471,9 @@ RemovePublishListMemberOperation, ) from ..models.share_report_operation import ShareReportOperation +from ..models.block_source_graph_operation import BlockSourceGraphOperation +from ..models.unblock_source_graph_operation import UnblockSourceGraphOperation +from ..models.revoke_report_share_operation import RevokeReportShareOperation from ..models.update_publish_list_operation import UpdatePublishListOperation from ..models.reopen_period_operation import ReopenPeriodOperation from ..models.set_close_target_operation import SetCloseTargetOperation @@ -488,6 +507,9 @@ from ..models.report_response import ReportResponse from ..models.schedule_created_response import ScheduleCreatedResponse from ..models.share_report_response import ShareReportResponse +from ..models.block_source_graph_result import BlockSourceGraphResult +from ..models.blocked_source_graph_response import BlockedSourceGraphResponse +from ..models.revoke_report_share_response import RevokeReportShareResponse from ..models.taxonomy_block_envelope import TaxonomyBlockEnvelope from ..types import UNSET @@ -2253,6 +2275,95 @@ def is_shared_report(self, report: dict[str, Any] | Any) -> bool: return report.get("source_graph_id") is not None return getattr(report, "source_graph_id", None) is not None + def revoke_report_share( + self, graph_id: str, report_id: str, target_graph_id: str + ) -> RevokeReportShareResponse: + """Withdraw a report previously shared to one recipient graph. + + The sender's half of the share controls: deletes the copy from that + recipient's schema and marks the share revoked. Scoped to a single + recipient, so withdrawing a distribution to a whole publish list is + one call per member. + + A recipient who already deleted the copy themselves is not an error — + the share is still marked revoked and ``copy_deleted`` comes back + False. The linked entity in the recipient's graph is left in place, so + an investor's declared holding survives the withdrawal. + """ + body = RevokeReportShareOperation( + report_id=report_id, target_graph_id=target_graph_id + ) + response = op_revoke_report_share( + graph_id=graph_id, body=body, client=self._get_client() + ) + envelope = self._call_op("Revoke report share", response) + return self._typed_result( + "Revoke report share", envelope, RevokeReportShareResponse + ) + + # ── Blocked source graphs ──────────────────────────────────────────── + # + # Sharing is authorized capability-style: whoever holds this graph's id + # can copy a published report into it. These are the recipient's exit. + + def list_blocked_source_graphs( + self, graph_id: str, limit: int = 100, offset: int = 0 + ) -> BlockedSourceGraphsPage | None: + """List source graphs barred from sharing reports into this graph.""" + data = self._query( + graph_id, + LIST_LEDGER_BLOCKED_SOURCE_GRAPHS_GQL, + {"limit": limit, "offset": offset}, + ) + return ListLedgerBlockedSourceGraphs.model_validate(data).blocked_source_graphs + + def block_source_graph( + self, + graph_id: str, + source_graph_id: str, + reason: str | None = None, + purge: bool = False, + ) -> BlockSourceGraphResult: + """Bar ``source_graph_id`` from sharing reports into ``graph_id``. + + Read the sender's id off the ``source_graph_id`` provenance field of a + report that was shared to you. Blocking is idempotent: re-blocking + returns ``already_blocked=True`` and preserves the original + ``blocked_at``. + + With ``purge``, every report already shared in from that source is + deleted along with its fact sets and facts; reports this graph + authored are never touched. ``reason`` is a note for your own records + and is never disclosed to the sender. + """ + body = BlockSourceGraphOperation( + source_graph_id=source_graph_id, + reason=reason if reason is not None else UNSET, + purge=purge, + ) + response = op_block_source_graph( + graph_id=graph_id, body=body, client=self._get_client() + ) + envelope = self._call_op("Block source graph", response) + return self._typed_result("Block source graph", envelope, BlockSourceGraphResult) + + def unblock_source_graph( + self, graph_id: str, source_graph_id: str + ) -> BlockedSourceGraphResponse: + """Lift a block, allowing that source to share in again. + + Reports removed by an earlier purge are not restored — unblocking + reopens the channel, it does not undo. + """ + body = UnblockSourceGraphOperation(source_graph_id=source_graph_id) + response = op_unblock_source_graph( + graph_id=graph_id, body=body, client=self._get_client() + ) + envelope = self._call_op("Unblock source graph", response) + return self._typed_result( + "Unblock source graph", envelope, BlockedSourceGraphResponse + ) + # ── Publish Lists ──────────────────────────────────────────────────── def list_publish_lists( diff --git a/robosystems_client/graphql/generated/__init__.py b/robosystems_client/graphql/generated/__init__.py index 552e9d1..c71471b 100644 --- a/robosystems_client/graphql/generated/__init__.py +++ b/robosystems_client/graphql/generated/__init__.py @@ -233,6 +233,12 @@ ListLedgerAccountsAccountsPagination, ) from .list_ledger_agents import ListLedgerAgents, ListLedgerAgentsAgents +from .list_ledger_blocked_source_graphs import ( + ListLedgerBlockedSourceGraphs, + ListLedgerBlockedSourceGraphsBlockedSourceGraphs, + ListLedgerBlockedSourceGraphsBlockedSourceGraphsBlockedSourceGraphs, + ListLedgerBlockedSourceGraphsBlockedSourceGraphsPagination, +) from .list_ledger_elements import ( ListLedgerElements, ListLedgerElementsElements, @@ -340,6 +346,7 @@ LIST_INVESTOR_SECURITIES_GQL, LIST_LEDGER_ACCOUNTS_GQL, LIST_LEDGER_AGENTS_GQL, + LIST_LEDGER_BLOCKED_SOURCE_GRAPHS_GQL, LIST_LEDGER_ELEMENTS_GQL, LIST_LEDGER_ENTITIES_GQL, LIST_LEDGER_EVENT_BLOCKS_GQL, @@ -547,6 +554,7 @@ "LIST_INVESTOR_SECURITIES_GQL", "LIST_LEDGER_ACCOUNTS_GQL", "LIST_LEDGER_AGENTS_GQL", + "LIST_LEDGER_BLOCKED_SOURCE_GRAPHS_GQL", "LIST_LEDGER_ELEMENTS_GQL", "LIST_LEDGER_ENTITIES_GQL", "LIST_LEDGER_EVENT_BLOCKS_GQL", @@ -598,6 +606,10 @@ "ListLedgerAccountsAccountsPagination", "ListLedgerAgents", "ListLedgerAgentsAgents", + "ListLedgerBlockedSourceGraphs", + "ListLedgerBlockedSourceGraphsBlockedSourceGraphs", + "ListLedgerBlockedSourceGraphsBlockedSourceGraphsBlockedSourceGraphs", + "ListLedgerBlockedSourceGraphsBlockedSourceGraphsPagination", "ListLedgerElements", "ListLedgerElementsElements", "ListLedgerElementsElementsElements", diff --git a/robosystems_client/graphql/generated/client.py b/robosystems_client/graphql/generated/client.py index a943461..1251576 100644 --- a/robosystems_client/graphql/generated/client.py +++ b/robosystems_client/graphql/generated/client.py @@ -40,6 +40,7 @@ from .list_investor_securities import ListInvestorSecurities from .list_ledger_accounts import ListLedgerAccounts from .list_ledger_agents import ListLedgerAgents +from .list_ledger_blocked_source_graphs import ListLedgerBlockedSourceGraphs from .list_ledger_elements import ListLedgerElements from .list_ledger_entities import ListLedgerEntities from .list_ledger_event_blocks import ListLedgerEventBlocks @@ -93,6 +94,7 @@ LIST_INVESTOR_SECURITIES_GQL, LIST_LEDGER_ACCOUNTS_GQL, LIST_LEDGER_AGENTS_GQL, + LIST_LEDGER_BLOCKED_SOURCE_GRAPHS_GQL, LIST_LEDGER_ELEMENTS_GQL, LIST_LEDGER_ENTITIES_GQL, LIST_LEDGER_EVENT_BLOCKS_GQL, @@ -614,6 +616,19 @@ def list_ledger_agents( data = self.get_data(response) return ListLedgerAgents.model_validate(data) + def list_ledger_blocked_source_graphs( + self, limit: int, offset: int, **kwargs: Any + ) -> ListLedgerBlockedSourceGraphs: + variables: dict[str, object] = {"limit": limit, "offset": offset} + response = self.execute( + query=LIST_LEDGER_BLOCKED_SOURCE_GRAPHS_GQL, + operation_name="ListLedgerBlockedSourceGraphs", + variables=variables, + **kwargs, + ) + data = self.get_data(response) + return ListLedgerBlockedSourceGraphs.model_validate(data) + def list_ledger_elements( self, limit: int, diff --git a/robosystems_client/graphql/generated/list_ledger_blocked_source_graphs.py b/robosystems_client/graphql/generated/list_ledger_blocked_source_graphs.py new file mode 100644 index 0000000..9d7f66e --- /dev/null +++ b/robosystems_client/graphql/generated/list_ledger_blocked_source_graphs.py @@ -0,0 +1,38 @@ +from typing import Optional + +from pydantic import Field + +from .base_model import BaseModel + + +class ListLedgerBlockedSourceGraphs(BaseModel): + blocked_source_graphs: Optional[ + "ListLedgerBlockedSourceGraphsBlockedSourceGraphs" + ] = Field(alias="blockedSourceGraphs") + + +class ListLedgerBlockedSourceGraphsBlockedSourceGraphs(BaseModel): + blocked_source_graphs: list[ + "ListLedgerBlockedSourceGraphsBlockedSourceGraphsBlockedSourceGraphs" + ] = Field(alias="blockedSourceGraphs") + pagination: "ListLedgerBlockedSourceGraphsBlockedSourceGraphsPagination" + + +class ListLedgerBlockedSourceGraphsBlockedSourceGraphsBlockedSourceGraphs(BaseModel): + id: str + source_graph_id: str = Field(alias="sourceGraphId") + source_graph_name: Optional[str] = Field(alias="sourceGraphName") + blocked_by: str = Field(alias="blockedBy") + blocked_at: str = Field(alias="blockedAt") + reason: Optional[str] + + +class ListLedgerBlockedSourceGraphsBlockedSourceGraphsPagination(BaseModel): + total: int + limit: int + offset: int + has_more: bool = Field(alias="hasMore") + + +ListLedgerBlockedSourceGraphs.model_rebuild() +ListLedgerBlockedSourceGraphsBlockedSourceGraphs.model_rebuild() diff --git a/robosystems_client/graphql/generated/operations.py b/robosystems_client/graphql/generated/operations.py index 66e64dc..a184d38 100644 --- a/robosystems_client/graphql/generated/operations.py +++ b/robosystems_client/graphql/generated/operations.py @@ -36,6 +36,7 @@ "LIST_INVESTOR_SECURITIES_GQL", "LIST_LEDGER_ACCOUNTS_GQL", "LIST_LEDGER_AGENTS_GQL", + "LIST_LEDGER_BLOCKED_SOURCE_GRAPHS_GQL", "LIST_LEDGER_ELEMENTS_GQL", "LIST_LEDGER_ENTITIES_GQL", "LIST_LEDGER_EVENT_BLOCKS_GQL", @@ -1365,6 +1366,27 @@ } """ +LIST_LEDGER_BLOCKED_SOURCE_GRAPHS_GQL = """ +query ListLedgerBlockedSourceGraphs($limit: Int! = 100, $offset: Int! = 0) { + blockedSourceGraphs(limit: $limit, offset: $offset) { + blockedSourceGraphs { + id + sourceGraphId + sourceGraphName + blockedBy + blockedAt + reason + } + pagination { + total + limit + offset + hasMore + } + } +} +""" + LIST_LEDGER_ELEMENTS_GQL = """ query ListLedgerElements($taxonomyId: String, $source: String, $classification: String, $isAbstract: Boolean, $limit: Int! = 100, $offset: Int! = 0) { elements( diff --git a/robosystems_client/graphql/operations/ledger/ListLedgerBlockedSourceGraphs.graphql b/robosystems_client/graphql/operations/ledger/ListLedgerBlockedSourceGraphs.graphql new file mode 100644 index 0000000..2eb49c8 --- /dev/null +++ b/robosystems_client/graphql/operations/ledger/ListLedgerBlockedSourceGraphs.graphql @@ -0,0 +1,9 @@ +query ListLedgerBlockedSourceGraphs($limit: Int! = 100, $offset: Int! = 0) { + blockedSourceGraphs(limit: $limit, offset: $offset) { + blockedSourceGraphs { + id sourceGraphId sourceGraphName + blockedBy blockedAt reason + } + pagination { total limit offset hasMore } + } +} diff --git a/robosystems_client/graphql/schema.graphql b/robosystems_client/graphql/schema.graphql index 199247c..41aa09d 100644 --- a/robosystems_client/graphql/schema.graphql +++ b/robosystems_client/graphql/schema.graphql @@ -44,6 +44,7 @@ type Query { statement(reportId: String!, blockType: String!): Statement publishLists(limit: Int = null, offset: Int = null): PublishListList publishList(listId: String!): PublishListDetail + blockedSourceGraphs(limit: Int = null, offset: Int = null): BlockedSourceGraphList informationBlock(id: ID!, scenarioId: String = null, series: Boolean = null, seriesHistory: Int = null, seriesForecast: Int = null): InformationBlock informationBlocks(blockType: String = null, category: String = null, limit: Int = null, offset: Int = null, scenarioId: String = null): [InformationBlock!]! taxonomyBlock(id: ID!): TaxonomyBlock @@ -2189,6 +2190,36 @@ type PublishListMember { addedAt: DateTime! } +"""Paginated list of blocked source graphs.""" +type BlockedSourceGraphList { + """Blocked source graphs.""" + blockedSourceGraphs: [BlockedSourceGraph!]! + + """Pagination metadata.""" + pagination: PaginationInfo! +} + +"""One blocked source graph.""" +type BlockedSourceGraph { + """Block row identifier (ULID).""" + id: String! + + """The blocked sender's graph ID.""" + sourceGraphId: String! + + """Display name of the blocked graph (if known).""" + sourceGraphName: String + + """User ID that created the block.""" + blockedBy: String! + + """When the block was created.""" + blockedAt: DateTime! + + """Recipient's own note, if given.""" + reason: String +} + type TaxonomyBlock { id: ID! name: String! diff --git a/robosystems_client/models/__init__.py b/robosystems_client/models/__init__.py index 43046de..29b75ac 100644 --- a/robosystems_client/models/__init__.py +++ b/robosystems_client/models/__init__.py @@ -36,6 +36,9 @@ from .billing_customer import BillingCustomer from .bind_text_block_request import BindTextBlockRequest from .bind_text_block_response import BindTextBlockResponse +from .block_source_graph_operation import BlockSourceGraphOperation +from .block_source_graph_result import BlockSourceGraphResult +from .blocked_source_graph_response import BlockedSourceGraphResponse from .cancel_operation_response_canceloperation import ( CancelOperationResponseCanceloperation, ) @@ -383,6 +386,18 @@ from .operation_envelope_bind_text_block_response_status import ( OperationEnvelopeBindTextBlockResponseStatus, ) +from .operation_envelope_block_source_graph_result import ( + OperationEnvelopeBlockSourceGraphResult, +) +from .operation_envelope_block_source_graph_result_status import ( + OperationEnvelopeBlockSourceGraphResultStatus, +) +from .operation_envelope_blocked_source_graph_response import ( + OperationEnvelopeBlockedSourceGraphResponse, +) +from .operation_envelope_blocked_source_graph_response_status import ( + OperationEnvelopeBlockedSourceGraphResponseStatus, +) from .operation_envelope_change_reporting_style_response import ( OperationEnvelopeChangeReportingStyleResponse, ) @@ -537,6 +552,12 @@ from .operation_envelope_report_response_status import ( OperationEnvelopeReportResponseStatus, ) +from .operation_envelope_revoke_report_share_response import ( + OperationEnvelopeRevokeReportShareResponse, +) +from .operation_envelope_revoke_report_share_response_status import ( + OperationEnvelopeRevokeReportShareResponseStatus, +) from .operation_envelope_schedule_created_response import ( OperationEnvelopeScheduleCreatedResponse, ) @@ -657,6 +678,8 @@ from .reset_password_validate_response import ResetPasswordValidateResponse from .resolved_report_info import ResolvedReportInfo from .response_mode import ResponseMode +from .revoke_report_share_operation import RevokeReportShareOperation +from .revoke_report_share_response import RevokeReportShareResponse from .rollforward_mechanics import RollforwardMechanics from .rollforward_mechanics_validation_mode import RollforwardMechanicsValidationMode from .rule_lite import RuleLite @@ -780,6 +803,7 @@ from .transaction_template_item import TransactionTemplateItem from .transaction_template_leg import TransactionTemplateLeg from .transition_filing_status_request import TransitionFilingStatusRequest +from .unblock_source_graph_operation import UnblockSourceGraphOperation from .upcoming_invoice import UpcomingInvoice from .update_agent_request import UpdateAgentRequest from .update_agent_request_address_type_0 import UpdateAgentRequestAddressType0 @@ -884,6 +908,9 @@ "BillingCustomer", "BindTextBlockRequest", "BindTextBlockResponse", + "BlockedSourceGraphResponse", + "BlockSourceGraphOperation", + "BlockSourceGraphResult", "CancelOperationResponseCanceloperation", "CancelSubscriptionRequest", "ChangeReportingStyleRequest", @@ -1157,6 +1184,10 @@ "OperationEnvelopeBackfillPlanHistoryResponseStatus", "OperationEnvelopeBindTextBlockResponse", "OperationEnvelopeBindTextBlockResponseStatus", + "OperationEnvelopeBlockedSourceGraphResponse", + "OperationEnvelopeBlockedSourceGraphResponseStatus", + "OperationEnvelopeBlockSourceGraphResult", + "OperationEnvelopeBlockSourceGraphResultStatus", "OperationEnvelopeChangeReportingStyleResponse", "OperationEnvelopeChangeReportingStyleResponseStatus", "OperationEnvelopeClosePeriodResponse", @@ -1213,6 +1244,8 @@ "OperationEnvelopePublishListResponseStatus", "OperationEnvelopeReportResponse", "OperationEnvelopeReportResponseStatus", + "OperationEnvelopeRevokeReportShareResponse", + "OperationEnvelopeRevokeReportShareResponseStatus", "OperationEnvelopeScheduleCreatedResponse", "OperationEnvelopeScheduleCreatedResponseStatus", "OperationEnvelopeSecurityResponse", @@ -1305,6 +1338,8 @@ "ResetPasswordValidateResponse", "ResolvedReportInfo", "ResponseMode", + "RevokeReportShareOperation", + "RevokeReportShareResponse", "RollforwardMechanics", "RollforwardMechanicsValidationMode", "RuleLite", @@ -1398,6 +1433,7 @@ "TransactionTemplateItem", "TransactionTemplateLeg", "TransitionFilingStatusRequest", + "UnblockSourceGraphOperation", "UpcomingInvoice", "UpdateAgentRequest", "UpdateAgentRequestAddressType0", diff --git a/robosystems_client/models/block_source_graph_operation.py b/robosystems_client/models/block_source_graph_operation.py new file mode 100644 index 0000000..a4e8179 --- /dev/null +++ b/robosystems_client/models/block_source_graph_operation.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="BlockSourceGraphOperation") + + +@_attrs_define +class BlockSourceGraphOperation: + """Bar a graph from sharing reports into this one. + + Attributes: + source_graph_id (str): Graph ID to block. Read it off the `source_graph_id` provenance field of a report that + was shared to you. + reason (None | str | Unset): Free-form note for your own records. Never disclosed to the sender. + purge (bool | Unset): Also delete every report already shared in from this source, with their fact sets and + facts. Reports you authored are never touched. Default: False. + """ + + source_graph_id: str + reason: None | str | Unset = UNSET + purge: bool | Unset = False + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + source_graph_id = self.source_graph_id + + reason: None | str | Unset + if isinstance(self.reason, Unset): + reason = UNSET + else: + reason = self.reason + + purge = self.purge + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source_graph_id": source_graph_id, + } + ) + if reason is not UNSET: + field_dict["reason"] = reason + if purge is not UNSET: + field_dict["purge"] = purge + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + source_graph_id = d.pop("source_graph_id") + + def _parse_reason(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + reason = _parse_reason(d.pop("reason", UNSET)) + + purge = d.pop("purge", UNSET) + + block_source_graph_operation = cls( + source_graph_id=source_graph_id, + reason=reason, + purge=purge, + ) + + block_source_graph_operation.additional_properties = d + return block_source_graph_operation + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/block_source_graph_result.py b/robosystems_client/models/block_source_graph_result.py new file mode 100644 index 0000000..15d128b --- /dev/null +++ b/robosystems_client/models/block_source_graph_result.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.blocked_source_graph_response import BlockedSourceGraphResponse + + +T = TypeVar("T", bound="BlockSourceGraphResult") + + +@_attrs_define +class BlockSourceGraphResult: + """Outcome of a block, including anything the purge removed. + + Attributes: + block (BlockedSourceGraphResponse): One blocked source graph. + already_blocked (bool | Unset): True when the source was already blocked and this call was a no-op apart from + any purge. Default: False. + purged_report_count (int | Unset): Number of previously-shared reports deleted from this graph. Zero unless + `purge` was set. Default: 0. + """ + + block: BlockedSourceGraphResponse + already_blocked: bool | Unset = False + purged_report_count: int | Unset = 0 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + block = self.block.to_dict() + + already_blocked = self.already_blocked + + purged_report_count = self.purged_report_count + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "block": block, + } + ) + if already_blocked is not UNSET: + field_dict["already_blocked"] = already_blocked + if purged_report_count is not UNSET: + field_dict["purged_report_count"] = purged_report_count + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.blocked_source_graph_response import BlockedSourceGraphResponse + + d = dict(src_dict) + block = BlockedSourceGraphResponse.from_dict(d.pop("block")) + + already_blocked = d.pop("already_blocked", UNSET) + + purged_report_count = d.pop("purged_report_count", UNSET) + + block_source_graph_result = cls( + block=block, + already_blocked=already_blocked, + purged_report_count=purged_report_count, + ) + + block_source_graph_result.additional_properties = d + return block_source_graph_result + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/blocked_source_graph_response.py b/robosystems_client/models/blocked_source_graph_response.py new file mode 100644 index 0000000..6b36e1d --- /dev/null +++ b/robosystems_client/models/blocked_source_graph_response.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="BlockedSourceGraphResponse") + + +@_attrs_define +class BlockedSourceGraphResponse: + """One blocked source graph. + + Attributes: + id (str): Block row identifier (ULID). + source_graph_id (str): The blocked sender's graph ID. + blocked_by (str): User ID that created the block. + blocked_at (datetime.datetime): When the block was created. + source_graph_name (None | str | Unset): Display name of the blocked graph (if known). + reason (None | str | Unset): Recipient's own note, if given. + """ + + id: str + source_graph_id: str + blocked_by: str + blocked_at: datetime.datetime + source_graph_name: None | str | Unset = UNSET + reason: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + id = self.id + + source_graph_id = self.source_graph_id + + blocked_by = self.blocked_by + + blocked_at = self.blocked_at.isoformat() + + source_graph_name: None | str | Unset + if isinstance(self.source_graph_name, Unset): + source_graph_name = UNSET + else: + source_graph_name = self.source_graph_name + + reason: None | str | Unset + if isinstance(self.reason, Unset): + reason = UNSET + else: + reason = self.reason + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "source_graph_id": source_graph_id, + "blocked_by": blocked_by, + "blocked_at": blocked_at, + } + ) + if source_graph_name is not UNSET: + field_dict["source_graph_name"] = source_graph_name + if reason is not UNSET: + field_dict["reason"] = reason + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = d.pop("id") + + source_graph_id = d.pop("source_graph_id") + + blocked_by = d.pop("blocked_by") + + blocked_at = datetime.datetime.fromisoformat(d.pop("blocked_at")) + + def _parse_source_graph_name(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + source_graph_name = _parse_source_graph_name(d.pop("source_graph_name", UNSET)) + + def _parse_reason(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + reason = _parse_reason(d.pop("reason", UNSET)) + + blocked_source_graph_response = cls( + id=id, + source_graph_id=source_graph_id, + blocked_by=blocked_by, + blocked_at=blocked_at, + source_graph_name=source_graph_name, + reason=reason, + ) + + blocked_source_graph_response.additional_properties = d + return blocked_source_graph_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/operation_envelope_block_source_graph_result.py b/robosystems_client/models/operation_envelope_block_source_graph_result.py new file mode 100644 index 0000000..a4c4bef --- /dev/null +++ b/robosystems_client/models/operation_envelope_block_source_graph_result.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.operation_envelope_block_source_graph_result_status import ( + OperationEnvelopeBlockSourceGraphResultStatus, +) +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.block_source_graph_result import BlockSourceGraphResult + + +T = TypeVar("T", bound="OperationEnvelopeBlockSourceGraphResult") + + +@_attrs_define +class OperationEnvelopeBlockSourceGraphResult: + """ + Attributes: + operation (str): Kebab-case operation name + operation_id (str): op_-prefixed ULID for audit and SSE correlation + status (OperationEnvelopeBlockSourceGraphResultStatus): Operation lifecycle state + at (str): ISO-8601 UTC timestamp + result (BlockSourceGraphResult | None | Unset): Command-specific result payload + created_by (None | str | Unset): User ID that initiated the operation (null for legacy callers) + idempotent_replay (bool | Unset): True when this envelope came from the idempotency cache — the underlying + command did not execute again. False on fresh executions. Default: False. + """ + + operation: str + operation_id: str + status: OperationEnvelopeBlockSourceGraphResultStatus + at: str + result: BlockSourceGraphResult | None | Unset = UNSET + created_by: None | str | Unset = UNSET + idempotent_replay: bool | Unset = False + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.block_source_graph_result import BlockSourceGraphResult + + operation = self.operation + + operation_id = self.operation_id + + status = self.status.value + + at = self.at + + result: dict[str, Any] | None | Unset + if isinstance(self.result, Unset): + result = UNSET + elif isinstance(self.result, BlockSourceGraphResult): + result = self.result.to_dict() + else: + result = self.result + + created_by: None | str | Unset + if isinstance(self.created_by, Unset): + created_by = UNSET + else: + created_by = self.created_by + + idempotent_replay = self.idempotent_replay + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "operation": operation, + "operationId": operation_id, + "status": status, + "at": at, + } + ) + if result is not UNSET: + field_dict["result"] = result + if created_by is not UNSET: + field_dict["createdBy"] = created_by + if idempotent_replay is not UNSET: + field_dict["idempotentReplay"] = idempotent_replay + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.block_source_graph_result import BlockSourceGraphResult + + d = dict(src_dict) + operation = d.pop("operation") + + operation_id = d.pop("operationId") + + status = OperationEnvelopeBlockSourceGraphResultStatus(d.pop("status")) + + at = d.pop("at") + + def _parse_result(data: object) -> BlockSourceGraphResult | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + result_type_0 = BlockSourceGraphResult.from_dict(data) + + return result_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(BlockSourceGraphResult | None | Unset, data) + + result = _parse_result(d.pop("result", UNSET)) + + def _parse_created_by(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + created_by = _parse_created_by(d.pop("createdBy", UNSET)) + + idempotent_replay = d.pop("idempotentReplay", UNSET) + + operation_envelope_block_source_graph_result = cls( + operation=operation, + operation_id=operation_id, + status=status, + at=at, + result=result, + created_by=created_by, + idempotent_replay=idempotent_replay, + ) + + operation_envelope_block_source_graph_result.additional_properties = d + return operation_envelope_block_source_graph_result + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/operation_envelope_block_source_graph_result_status.py b/robosystems_client/models/operation_envelope_block_source_graph_result_status.py new file mode 100644 index 0000000..f623046 --- /dev/null +++ b/robosystems_client/models/operation_envelope_block_source_graph_result_status.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class OperationEnvelopeBlockSourceGraphResultStatus(str, Enum): + COMPLETED = "completed" + FAILED = "failed" + PENDING = "pending" + + def __str__(self) -> str: + return str(self.value) diff --git a/robosystems_client/models/operation_envelope_blocked_source_graph_response.py b/robosystems_client/models/operation_envelope_blocked_source_graph_response.py new file mode 100644 index 0000000..8623683 --- /dev/null +++ b/robosystems_client/models/operation_envelope_blocked_source_graph_response.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.operation_envelope_blocked_source_graph_response_status import ( + OperationEnvelopeBlockedSourceGraphResponseStatus, +) +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.blocked_source_graph_response import BlockedSourceGraphResponse + + +T = TypeVar("T", bound="OperationEnvelopeBlockedSourceGraphResponse") + + +@_attrs_define +class OperationEnvelopeBlockedSourceGraphResponse: + """ + Attributes: + operation (str): Kebab-case operation name + operation_id (str): op_-prefixed ULID for audit and SSE correlation + status (OperationEnvelopeBlockedSourceGraphResponseStatus): Operation lifecycle state + at (str): ISO-8601 UTC timestamp + result (BlockedSourceGraphResponse | None | Unset): Command-specific result payload + created_by (None | str | Unset): User ID that initiated the operation (null for legacy callers) + idempotent_replay (bool | Unset): True when this envelope came from the idempotency cache — the underlying + command did not execute again. False on fresh executions. Default: False. + """ + + operation: str + operation_id: str + status: OperationEnvelopeBlockedSourceGraphResponseStatus + at: str + result: BlockedSourceGraphResponse | None | Unset = UNSET + created_by: None | str | Unset = UNSET + idempotent_replay: bool | Unset = False + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.blocked_source_graph_response import BlockedSourceGraphResponse + + operation = self.operation + + operation_id = self.operation_id + + status = self.status.value + + at = self.at + + result: dict[str, Any] | None | Unset + if isinstance(self.result, Unset): + result = UNSET + elif isinstance(self.result, BlockedSourceGraphResponse): + result = self.result.to_dict() + else: + result = self.result + + created_by: None | str | Unset + if isinstance(self.created_by, Unset): + created_by = UNSET + else: + created_by = self.created_by + + idempotent_replay = self.idempotent_replay + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "operation": operation, + "operationId": operation_id, + "status": status, + "at": at, + } + ) + if result is not UNSET: + field_dict["result"] = result + if created_by is not UNSET: + field_dict["createdBy"] = created_by + if idempotent_replay is not UNSET: + field_dict["idempotentReplay"] = idempotent_replay + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.blocked_source_graph_response import BlockedSourceGraphResponse + + d = dict(src_dict) + operation = d.pop("operation") + + operation_id = d.pop("operationId") + + status = OperationEnvelopeBlockedSourceGraphResponseStatus(d.pop("status")) + + at = d.pop("at") + + def _parse_result(data: object) -> BlockedSourceGraphResponse | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + result_type_0 = BlockedSourceGraphResponse.from_dict(data) + + return result_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(BlockedSourceGraphResponse | None | Unset, data) + + result = _parse_result(d.pop("result", UNSET)) + + def _parse_created_by(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + created_by = _parse_created_by(d.pop("createdBy", UNSET)) + + idempotent_replay = d.pop("idempotentReplay", UNSET) + + operation_envelope_blocked_source_graph_response = cls( + operation=operation, + operation_id=operation_id, + status=status, + at=at, + result=result, + created_by=created_by, + idempotent_replay=idempotent_replay, + ) + + operation_envelope_blocked_source_graph_response.additional_properties = d + return operation_envelope_blocked_source_graph_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/operation_envelope_blocked_source_graph_response_status.py b/robosystems_client/models/operation_envelope_blocked_source_graph_response_status.py new file mode 100644 index 0000000..aa95b5a --- /dev/null +++ b/robosystems_client/models/operation_envelope_blocked_source_graph_response_status.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class OperationEnvelopeBlockedSourceGraphResponseStatus(str, Enum): + COMPLETED = "completed" + FAILED = "failed" + PENDING = "pending" + + def __str__(self) -> str: + return str(self.value) diff --git a/robosystems_client/models/operation_envelope_revoke_report_share_response.py b/robosystems_client/models/operation_envelope_revoke_report_share_response.py new file mode 100644 index 0000000..4c22cbc --- /dev/null +++ b/robosystems_client/models/operation_envelope_revoke_report_share_response.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.operation_envelope_revoke_report_share_response_status import ( + OperationEnvelopeRevokeReportShareResponseStatus, +) +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.revoke_report_share_response import RevokeReportShareResponse + + +T = TypeVar("T", bound="OperationEnvelopeRevokeReportShareResponse") + + +@_attrs_define +class OperationEnvelopeRevokeReportShareResponse: + """ + Attributes: + operation (str): Kebab-case operation name + operation_id (str): op_-prefixed ULID for audit and SSE correlation + status (OperationEnvelopeRevokeReportShareResponseStatus): Operation lifecycle state + at (str): ISO-8601 UTC timestamp + result (None | RevokeReportShareResponse | Unset): Command-specific result payload + created_by (None | str | Unset): User ID that initiated the operation (null for legacy callers) + idempotent_replay (bool | Unset): True when this envelope came from the idempotency cache — the underlying + command did not execute again. False on fresh executions. Default: False. + """ + + operation: str + operation_id: str + status: OperationEnvelopeRevokeReportShareResponseStatus + at: str + result: None | RevokeReportShareResponse | Unset = UNSET + created_by: None | str | Unset = UNSET + idempotent_replay: bool | Unset = False + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.revoke_report_share_response import RevokeReportShareResponse + + operation = self.operation + + operation_id = self.operation_id + + status = self.status.value + + at = self.at + + result: dict[str, Any] | None | Unset + if isinstance(self.result, Unset): + result = UNSET + elif isinstance(self.result, RevokeReportShareResponse): + result = self.result.to_dict() + else: + result = self.result + + created_by: None | str | Unset + if isinstance(self.created_by, Unset): + created_by = UNSET + else: + created_by = self.created_by + + idempotent_replay = self.idempotent_replay + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "operation": operation, + "operationId": operation_id, + "status": status, + "at": at, + } + ) + if result is not UNSET: + field_dict["result"] = result + if created_by is not UNSET: + field_dict["createdBy"] = created_by + if idempotent_replay is not UNSET: + field_dict["idempotentReplay"] = idempotent_replay + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.revoke_report_share_response import RevokeReportShareResponse + + d = dict(src_dict) + operation = d.pop("operation") + + operation_id = d.pop("operationId") + + status = OperationEnvelopeRevokeReportShareResponseStatus(d.pop("status")) + + at = d.pop("at") + + def _parse_result(data: object) -> None | RevokeReportShareResponse | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + result_type_0 = RevokeReportShareResponse.from_dict(data) + + return result_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | RevokeReportShareResponse | Unset, data) + + result = _parse_result(d.pop("result", UNSET)) + + def _parse_created_by(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + created_by = _parse_created_by(d.pop("createdBy", UNSET)) + + idempotent_replay = d.pop("idempotentReplay", UNSET) + + operation_envelope_revoke_report_share_response = cls( + operation=operation, + operation_id=operation_id, + status=status, + at=at, + result=result, + created_by=created_by, + idempotent_replay=idempotent_replay, + ) + + operation_envelope_revoke_report_share_response.additional_properties = d + return operation_envelope_revoke_report_share_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/operation_envelope_revoke_report_share_response_status.py b/robosystems_client/models/operation_envelope_revoke_report_share_response_status.py new file mode 100644 index 0000000..2d3fbf6 --- /dev/null +++ b/robosystems_client/models/operation_envelope_revoke_report_share_response_status.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class OperationEnvelopeRevokeReportShareResponseStatus(str, Enum): + COMPLETED = "completed" + FAILED = "failed" + PENDING = "pending" + + def __str__(self) -> str: + return str(self.value) diff --git a/robosystems_client/models/org_usage_summary.py b/robosystems_client/models/org_usage_summary.py index 3fb789d..e8155fc 100644 --- a/robosystems_client/models/org_usage_summary.py +++ b/robosystems_client/models/org_usage_summary.py @@ -17,26 +17,18 @@ class OrgUsageSummary: total_credits_used (float): total_ai_operations (int): total_storage_gb (float): - total_api_calls (int): daily_avg_credits (float): - daily_avg_api_calls (float): projected_monthly_credits (float): - projected_monthly_api_calls (int): credits_limit (int | None): - api_calls_limit (int | None): storage_limit_gb (int | None): """ total_credits_used: float total_ai_operations: int total_storage_gb: float - total_api_calls: int daily_avg_credits: float - daily_avg_api_calls: float projected_monthly_credits: float - projected_monthly_api_calls: int credits_limit: int | None - api_calls_limit: int | None storage_limit_gb: int | None additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -47,22 +39,13 @@ def to_dict(self) -> dict[str, Any]: total_storage_gb = self.total_storage_gb - total_api_calls = self.total_api_calls - daily_avg_credits = self.daily_avg_credits - daily_avg_api_calls = self.daily_avg_api_calls - projected_monthly_credits = self.projected_monthly_credits - projected_monthly_api_calls = self.projected_monthly_api_calls - credits_limit: int | None credits_limit = self.credits_limit - api_calls_limit: int | None - api_calls_limit = self.api_calls_limit - storage_limit_gb: int | None storage_limit_gb = self.storage_limit_gb @@ -73,13 +56,9 @@ def to_dict(self) -> dict[str, Any]: "total_credits_used": total_credits_used, "total_ai_operations": total_ai_operations, "total_storage_gb": total_storage_gb, - "total_api_calls": total_api_calls, "daily_avg_credits": daily_avg_credits, - "daily_avg_api_calls": daily_avg_api_calls, "projected_monthly_credits": projected_monthly_credits, - "projected_monthly_api_calls": projected_monthly_api_calls, "credits_limit": credits_limit, - "api_calls_limit": api_calls_limit, "storage_limit_gb": storage_limit_gb, } ) @@ -95,16 +74,10 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: total_storage_gb = d.pop("total_storage_gb") - total_api_calls = d.pop("total_api_calls") - daily_avg_credits = d.pop("daily_avg_credits") - daily_avg_api_calls = d.pop("daily_avg_api_calls") - projected_monthly_credits = d.pop("projected_monthly_credits") - projected_monthly_api_calls = d.pop("projected_monthly_api_calls") - def _parse_credits_limit(data: object) -> int | None: if data is None: return data @@ -112,13 +85,6 @@ def _parse_credits_limit(data: object) -> int | None: credits_limit = _parse_credits_limit(d.pop("credits_limit")) - def _parse_api_calls_limit(data: object) -> int | None: - if data is None: - return data - return cast(int | None, data) - - api_calls_limit = _parse_api_calls_limit(d.pop("api_calls_limit")) - def _parse_storage_limit_gb(data: object) -> int | None: if data is None: return data @@ -130,13 +96,9 @@ def _parse_storage_limit_gb(data: object) -> int | None: total_credits_used=total_credits_used, total_ai_operations=total_ai_operations, total_storage_gb=total_storage_gb, - total_api_calls=total_api_calls, daily_avg_credits=daily_avg_credits, - daily_avg_api_calls=daily_avg_api_calls, projected_monthly_credits=projected_monthly_credits, - projected_monthly_api_calls=projected_monthly_api_calls, credits_limit=credits_limit, - api_calls_limit=api_calls_limit, storage_limit_gb=storage_limit_gb, ) diff --git a/robosystems_client/models/revoke_report_share_operation.py b/robosystems_client/models/revoke_report_share_operation.py new file mode 100644 index 0000000..f0e79f9 --- /dev/null +++ b/robosystems_client/models/revoke_report_share_operation.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="RevokeReportShareOperation") + + +@_attrs_define +class RevokeReportShareOperation: + """Withdraw a shared Report from one recipient graph. + + Attributes: + target_graph_id (str): Recipient graph whose copy should be withdrawn. + report_id (str): The Report whose share to withdraw. + """ + + target_graph_id: str + report_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + target_graph_id = self.target_graph_id + + report_id = self.report_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "target_graph_id": target_graph_id, + "report_id": report_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + target_graph_id = d.pop("target_graph_id") + + report_id = d.pop("report_id") + + revoke_report_share_operation = cls( + target_graph_id=target_graph_id, + report_id=report_id, + ) + + revoke_report_share_operation.additional_properties = d + return revoke_report_share_operation + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/revoke_report_share_response.py b/robosystems_client/models/revoke_report_share_response.py new file mode 100644 index 0000000..ae2bacd --- /dev/null +++ b/robosystems_client/models/revoke_report_share_response.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="RevokeReportShareResponse") + + +@_attrs_define +class RevokeReportShareResponse: + """Outcome of withdrawing a shared report from one recipient. + + Attributes: + report_id (str): The report whose share was revoked. + target_graph_id (str): Recipient the copy was pulled from. + revoked_at (datetime.datetime): When the share was revoked. + copy_deleted (bool): True when a copy was found and deleted in the recipient's schema. False when the recipient + had already deleted it themselves — the share is still marked revoked. + """ + + report_id: str + target_graph_id: str + revoked_at: datetime.datetime + copy_deleted: bool + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + report_id = self.report_id + + target_graph_id = self.target_graph_id + + revoked_at = self.revoked_at.isoformat() + + copy_deleted = self.copy_deleted + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "report_id": report_id, + "target_graph_id": target_graph_id, + "revoked_at": revoked_at, + "copy_deleted": copy_deleted, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + report_id = d.pop("report_id") + + target_graph_id = d.pop("target_graph_id") + + revoked_at = datetime.datetime.fromisoformat(d.pop("revoked_at")) + + copy_deleted = d.pop("copy_deleted") + + revoke_report_share_response = cls( + report_id=report_id, + target_graph_id=target_graph_id, + revoked_at=revoked_at, + copy_deleted=copy_deleted, + ) + + revoke_report_share_response.additional_properties = d + return revoke_report_share_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/unblock_source_graph_operation.py b/robosystems_client/models/unblock_source_graph_operation.py new file mode 100644 index 0000000..4cf012e --- /dev/null +++ b/robosystems_client/models/unblock_source_graph_operation.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="UnblockSourceGraphOperation") + + +@_attrs_define +class UnblockSourceGraphOperation: + """Lift a block on a source graph. + + Attributes: + source_graph_id (str): Graph ID to unblock. + """ + + source_graph_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + source_graph_id = self.source_graph_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source_graph_id": source_graph_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + source_graph_id = d.pop("source_graph_id") + + unblock_source_graph_operation = cls( + source_graph_id=source_graph_id, + ) + + unblock_source_graph_operation.additional_properties = d + return unblock_source_graph_operation + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/tests/test_ledger_client.py b/tests/test_ledger_client.py index 27e130a..2be7918 100644 --- a/tests/test_ledger_client.py +++ b/tests/test_ledger_client.py @@ -2247,3 +2247,154 @@ def test_compute_metrics_forwards_scenario_and_idempotency_key( body = mock_op.call_args.kwargs["body"] assert body.scenario_id == "struct_fc" assert mock_op.call_args.kwargs["idempotency_key"] == "idem-metrics-1" + + +# ── Cross-graph share controls ───────────────────────────────────────── +# +# Sharing is authorized capability-style — whoever holds a graph's id can +# copy a published report into it — so the recipient's exit (block, purge) +# and the sender's (revoke) are what make the model sound. + + +@pytest.mark.unit +class TestShareControls: + @patch("robosystems_client.clients.ledger_client.op_block_source_graph") + def test_block_source_graph_unwraps_envelope(self, mock_op, mock_config, graph_id): + envelope = _envelope( + "block-source-graph", + { + "block": { + "id": "blk_1", + "source_graph_id": "kg_sender", + "source_graph_name": "Acme Inc", + "blocked_by": "usr_1", + "blocked_at": "2026-08-09T12:00:00Z", + "reason": None, + }, + "already_blocked": False, + "purged_report_count": 0, + }, + ) + mock_op.return_value = _mock_response(envelope) + client = LedgerClient(mock_config) + result = client.block_source_graph(graph_id, "kg_sender") + assert result["block"]["source_graph_id"] == "kg_sender" + assert result["already_blocked"] is False + assert result["purged_report_count"] == 0 + body = mock_op.call_args.kwargs["body"] + assert body.source_graph_id == "kg_sender" + assert body.purge is False + # An omitted reason must not be sent as an explicit null. + assert body.reason is UNSET + + @patch("robosystems_client.clients.ledger_client.op_block_source_graph") + def test_block_source_graph_forwards_purge_and_reason( + self, mock_op, mock_config, graph_id + ): + envelope = _envelope( + "block-source-graph", + { + "block": { + "id": "blk_1", + "source_graph_id": "kg_sender", + "source_graph_name": None, + "blocked_by": "usr_1", + "blocked_at": "2026-08-09T12:00:00Z", + "reason": "No longer a shareholder.", + }, + "already_blocked": True, + "purged_report_count": 3, + }, + ) + mock_op.return_value = _mock_response(envelope) + client = LedgerClient(mock_config) + result = client.block_source_graph( + graph_id, "kg_sender", reason="No longer a shareholder.", purge=True + ) + assert result["already_blocked"] is True + assert result["purged_report_count"] == 3 + body = mock_op.call_args.kwargs["body"] + assert body.purge is True + assert body.reason == "No longer a shareholder." + + @patch("robosystems_client.clients.ledger_client.op_unblock_source_graph") + def test_unblock_source_graph(self, mock_op, mock_config, graph_id): + envelope = _envelope( + "unblock-source-graph", + { + "id": "blk_1", + "source_graph_id": "kg_sender", + "source_graph_name": "Acme Inc", + "blocked_by": "usr_1", + "blocked_at": "2026-08-09T12:00:00Z", + "reason": None, + }, + ) + mock_op.return_value = _mock_response(envelope) + client = LedgerClient(mock_config) + result = client.unblock_source_graph(graph_id, "kg_sender") + assert result["source_graph_id"] == "kg_sender" + assert mock_op.call_args.kwargs["body"].source_graph_id == "kg_sender" + + @patch("robosystems_client.clients.ledger_client.op_revoke_report_share") + def test_revoke_report_share(self, mock_op, mock_config, graph_id): + envelope = _envelope( + "revoke-report-share", + { + "report_id": "rpt_1", + "target_graph_id": "kg_recipient", + "revoked_at": "2026-08-09T12:00:00Z", + "copy_deleted": True, + }, + ) + mock_op.return_value = _mock_response(envelope) + client = LedgerClient(mock_config) + result = client.revoke_report_share(graph_id, "rpt_1", "kg_recipient") + assert result["copy_deleted"] is True + assert result["target_graph_id"] == "kg_recipient" + body = mock_op.call_args.kwargs["body"] + assert body.report_id == "rpt_1" + assert body.target_graph_id == "kg_recipient" + + @patch("robosystems_client.clients.ledger_client.op_revoke_report_share") + def test_revoke_report_share_when_recipient_already_deleted( + self, mock_op, mock_config, graph_id + ): + """Not an error — the recipient exercising their own exit first still + leaves the sender's record honest.""" + envelope = _envelope( + "revoke-report-share", + { + "report_id": "rpt_1", + "target_graph_id": "kg_recipient", + "revoked_at": "2026-08-09T12:00:00Z", + "copy_deleted": False, + }, + ) + mock_op.return_value = _mock_response(envelope) + client = LedgerClient(mock_config) + result = client.revoke_report_share(graph_id, "rpt_1", "kg_recipient") + assert result["copy_deleted"] is False + + @patch("robosystems_client.clients.ledger_client.LedgerClient._query") + def test_list_blocked_source_graphs(self, mock_query, mock_config, graph_id): + mock_query.return_value = { + "blockedSourceGraphs": { + "blockedSourceGraphs": [ + { + "id": "blk_1", + "sourceGraphId": "kg_sender", + "sourceGraphName": "Acme Inc", + "blockedBy": "usr_1", + "blockedAt": "2026-08-09T12:00:00Z", + "reason": None, + } + ], + "pagination": {"total": 1, "limit": 100, "offset": 0, "hasMore": False}, + } + } + client = LedgerClient(mock_config) + page = client.list_blocked_source_graphs(graph_id) + assert page is not None + assert page.blocked_source_graphs[0].source_graph_id == "kg_sender" + assert page.pagination.total == 1 From 78c3ce87386104a6475fbd6e4b4710ff5f3ab008 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Sun, 9 Aug 2026 15:51:33 -0500 Subject: [PATCH 2/3] chore: regenerate unblock-source-graph description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the backend's admin-role requirement on unblock, added after the initial generation. Docstring only — path, operationId, request and response models are unchanged, so the facade is untouched. --- .../unblock_source_graph.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/robosystems_client/api/extensions_robo_ledger/unblock_source_graph.py b/robosystems_client/api/extensions_robo_ledger/unblock_source_graph.py index ee215e1..3f31116 100644 --- a/robosystems_client/api/extensions_robo_ledger/unblock_source_graph.py +++ b/robosystems_client/api/extensions_robo_ledger/unblock_source_graph.py @@ -116,8 +116,9 @@ def sync_detailed( """Unblock Source Graph Lifts a block, allowing that graph to share reports into this one again. Reports removed by an - earlier purge are not restored — unblocking reopens the channel, it does not undo. Returns 404 when - the source was not blocked. + earlier purge are not restored — unblocking reopens the channel, it does not undo. Requires the + graph admin role: a block is a standing decision about who may write into this graph, so a member + cannot reverse it over an admin's head. Returns 404 when the source was not blocked. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -158,8 +159,9 @@ def sync( """Unblock Source Graph Lifts a block, allowing that graph to share reports into this one again. Reports removed by an - earlier purge are not restored — unblocking reopens the channel, it does not undo. Returns 404 when - the source was not blocked. + earlier purge are not restored — unblocking reopens the channel, it does not undo. Requires the + graph admin role: a block is a standing decision about who may write into this graph, so a member + cannot reverse it over an admin's head. Returns 404 when the source was not blocked. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -195,8 +197,9 @@ async def asyncio_detailed( """Unblock Source Graph Lifts a block, allowing that graph to share reports into this one again. Reports removed by an - earlier purge are not restored — unblocking reopens the channel, it does not undo. Returns 404 when - the source was not blocked. + earlier purge are not restored — unblocking reopens the channel, it does not undo. Requires the + graph admin role: a block is a standing decision about who may write into this graph, so a member + cannot reverse it over an admin's head. Returns 404 when the source was not blocked. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -235,8 +238,9 @@ async def asyncio( """Unblock Source Graph Lifts a block, allowing that graph to share reports into this one again. Reports removed by an - earlier purge are not restored — unblocking reopens the channel, it does not undo. Returns 404 when - the source was not blocked. + earlier purge are not restored — unblocking reopens the channel, it does not undo. Requires the + graph admin role: a block is a standing decision about who may write into this graph, so a member + cannot reverse it over an admin's head. Returns 404 when the source was not blocked. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. From 2ea0442776e61ef95d8356a321d345d7c03fd337 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Sun, 9 Aug 2026 15:56:23 -0500 Subject: [PATCH 3/3] docs: curated release notes for v1.8.0 --- .github/release-notes/v1.8.0.md | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/release-notes/v1.8.0.md diff --git a/.github/release-notes/v1.8.0.md b/.github/release-notes/v1.8.0.md new file mode 100644 index 0000000..465dbe7 --- /dev/null +++ b/.github/release-notes/v1.8.0.md @@ -0,0 +1,46 @@ +Cross-graph report sharing gets its controls, on both sides. + +Sharing pushes a published report from one graph into another's books, across +the organization boundary by design — a company distributing its quarterly +statements to investors who are, by definition, somewhere else. Authorization +is capability-style: possession of the recipient's `graph_id` is sufficient, +because the only way to hold one is that the recipient handed it over. + +That model is only sound if the recipient can refuse. This release adds the +methods that let them, and the one that lets a sender take a report back. + +## New on `LedgerClient` + +- **`block_source_graph(graph_id, source_graph_id, reason=None, purge=False)`** + — bars a sender from sharing reports into your graph. Read their id off the + `source_graph_id` provenance field of a report they sent you. Idempotent: + re-blocking preserves the original `blocked_at`. With `purge=True`, reports + already received from that sender are deleted along with their fact sets and + facts; reports you authored are never eligible. `reason` is a note for your + own records and is never disclosed to the sender. Purging requires the graph + admin role; plain blocking does not. +- **`unblock_source_graph(graph_id, source_graph_id)`** — reopens the channel. + Purged reports are not restored: unblocking undoes the block, not the purge. + Requires the graph admin role. +- **`revoke_report_share(graph_id, report_id, target_graph_id)`** — the + sender's half. Deletes the delivered copy from that recipient's books and + marks the share revoked. Scoped to one recipient, so a withdrawal is always + deliberate. A recipient who already deleted the copy is not an error — the + share is still marked revoked and `copy_deleted` returns `False`. +- **`list_blocked_source_graphs(graph_id, limit=100, offset=0)`** — the read + side, returning who is blocked, by whom, when, and why. + +A report shared *in* from another graph can now also be deleted by an admin of +the receiving graph. The copy carries the sender's user id, so the ordinary +owner rule could never have matched anyone on the receiving side. + +## Generated tier + +`OrgUsageSummary` loses four fields: `total_api_calls`, `daily_avg_api_calls`, +`projected_monthly_api_calls`, and `api_calls_limit`. The platform never +populated them with anything but zero, and they have been removed upstream +rather than left to imply a measurement that was not happening. If you read +them, they were reporting nothing. + +No facade method changes shape, and nothing on the stable tier is removed or +deprecated.