diff --git a/doc/cli/other-commands.md b/doc/cli/other-commands.md index 8958c8d7..00e05651 100644 --- a/doc/cli/other-commands.md +++ b/doc/cli/other-commands.md @@ -158,6 +158,19 @@ Redacted notes cannot be edited or have their visibility changed. A nonzero `images_pending_deletion` means image blobs still await cleanup; it does not mean every image has been deleted. Repeating redaction retries cleanup. +The SDK accepts optional keyword markers for the AI actor editing or redacting: +`cases.redact_note(42, event_id, ai_session_id="session-id", ai_client="client-name")` +or `cases.edit_note(42, event_id, "Corrected text", ai_session_id="session-id", ai_client="client-name")`. +These identify the current AI actor, not the original note author. +`ai_client` identifies outside AI, such as an MCP server. People and automation +send no AI marker; the service classifies them from the caller's identity. +Like `add_note`, an omitted or empty `ai_session_id` falls back to +`LC_AI_SESSION_ID`; `ai_client` has no environment fallback. Without either +marker, redaction keeps the existing POST with no body and editing keeps +its JSON body containing only `content`. Nonempty markers are added to the +request JSON when present. The CLI automatically uses +`LC_AI_SESSION_ID` through the SDK; no new CLI setting is required. + Redaction deletes active records, not recovery copies: text may remain in point-in-time recovery for up to **7 days** and backups for up to **30 days**. Already delivered hooks-domain audit events follow the organization's retention diff --git a/limacharlie/commands/case_cmd.py b/limacharlie/commands/case_cmd.py index 1fc60ec0..b68b9545 100644 --- a/limacharlie/commands/case_cmd.py +++ b/limacharlie/commands/case_cmd.py @@ -138,6 +138,8 @@ Content must be nonempty and at most 8192 UTF-8 bytes. The API permits 100 actual edits per note; identical text is a no-op. After the limit, add a new note. Redacted notes cannot be edited. Requires investigation.set. +LC_AI_SESSION_ID, when set, attributes the edit to the current AI session +through the SDK, rather than to the original note author. Example: limacharlie case edit-note --case-number 42 --event-id --content "Corrected notes" @@ -149,6 +151,8 @@ Redacted notes cannot be edited or have their visibility changed. Image downloads are blocked immediately; images_pending_deletion reports blobs still awaiting cleanup. Repeating redaction retries pending cleanup. +LC_AI_SESSION_ID, when set, attributes redaction to the current AI session +through the SDK, rather than to the original note author. Text can remain in point-in-time recovery for up to 7 days and backups for up to 30 days. Delivered audit events, downloaded images, existing client copies, queued payloads and logs cannot be recalled. Text submitted through diff --git a/limacharlie/sdk/cases.py b/limacharlie/sdk/cases.py index bbb04ac7..2d69fedb 100644 --- a/limacharlie/sdk/cases.py +++ b/limacharlie/sdk/cases.py @@ -227,16 +227,26 @@ def add_note( def edit_note( self, case_number: int, event_id: str, content: str, + *, + ai_session_id: str | None = None, + ai_client: str | None = None, ) -> dict[str, Any]: """Edit a note while preserving earlier text in the case history. The API permits 100 actual edits per note; identical text is a no-op. Redacted notes cannot be edited. Requires investigation.set. + People and automation send no AI marker; the service classifies them + from caller identity. With no markers, the body contains only content. Args: case_number: Case number. event_id: Original note event ID from the case timeline. content: Nonempty replacement text, at most 8192 UTF-8 bytes. + ai_session_id: Session of the editor, rather than the original note + author. Falls back to ``LC_AI_SESSION_ID`` when omitted or + empty, matching add_note and redact_note. + ai_client: Optional marker for an outside AI editor (for example, + MCP). No environment fallback is applied. Returns: dict: Updated note in ``event``. @@ -247,14 +257,27 @@ def edit_note( Malformed event IDs return HTTP 400; transient transaction failures return HTTP 503 and can be retried. """ + body: dict[str, Any] = {"content": content} + session_id = ai_session_id or os.environ.get("LC_AI_SESSION_ID") + if session_id: + body["ai_session_id"] = session_id + if ai_client: + body["ai_client"] = ai_client return self._request( "PUT", f"cases/{case_number}/notes/{quote(event_id, safe='')}/content", query_params={"oid": self.oid}, - body={"content": content}, + body=body, ) - def redact_note(self, case_number: int, event_id: str) -> dict[str, Any]: + def redact_note( + self, + case_number: int, + event_id: str, + *, + ai_session_id: str | None = None, + ai_client: str | None = None, + ) -> dict[str, Any]: """Irreversibly redact a note and its earlier versions from active storage. Redacted notes cannot be edited or have their visibility changed. @@ -262,10 +285,18 @@ def redact_note(self, case_number: int, event_id: str) -> dict[str, Any]: investigation.set. Recovery copies can retain text for up to 7 days in point-in-time recovery and 30 days in backups. Delivered audit events, downloaded images, client copies and logs cannot be recalled. + With no actor markers, the POST has no body for backward compatibility; + otherwise only nonempty markers are sent as JSON. People and automation + send no AI marker; the service classifies them from caller identity. Args: case_number: Case number. event_id: Original note event ID from the case timeline. + ai_session_id: Session of the actor performing redaction, rather + than the original note author. Falls back to ``LC_AI_SESSION_ID`` + when omitted or empty, matching add_note. + ai_client: Optional marker for an outside AI client performing + redaction (for example, MCP). No environment fallback is applied. Returns: dict: Redacted ``event`` and ``images_pending_deletion`` count. @@ -277,10 +308,17 @@ def redact_note(self, case_number: int, event_id: str) -> dict[str, Any]: Malformed event IDs return HTTP 400; transient transaction failures return HTTP 503 and can be retried. """ + body: dict[str, Any] = {} + session_id = ai_session_id or os.environ.get("LC_AI_SESSION_ID") + if session_id: + body["ai_session_id"] = session_id + if ai_client: + body["ai_client"] = ai_client return self._request( "POST", f"cases/{case_number}/notes/{quote(event_id, safe='')}/redact", query_params={"oid": self.oid}, + body=body or None, ) def log_time( diff --git a/tests/unit/test_case_note_mutations.py b/tests/unit/test_case_note_mutations.py index b63ec1e3..a2ac19fc 100644 --- a/tests/unit/test_case_note_mutations.py +++ b/tests/unit/test_case_note_mutations.py @@ -12,7 +12,8 @@ @pytest.fixture -def org(): +def org(monkeypatch): + monkeypatch.delenv("LC_AI_SESSION_ID", raising=False) org = MagicMock() org.oid = "test-oid" org.get_urls.return_value = {"cases": "cases-staging.limacharlie.io"} @@ -192,3 +193,99 @@ def test_update_note_help_explains_redacted_conflict(): result = CliRunner().invoke(cli, ["case", "update-note", flag]) assert result.exit_code == 0 assert "409" in result.output + + +@pytest.mark.parametrize("markers,env_session,expected", [ + ({"ai_session_id": "current-session"}, None, {"ai_session_id": "current-session"}), + ({"ai_client": "outside-ai-client"}, None, {"ai_client": "outside-ai-client"}), + ({"ai_session_id": "current-session", "ai_client": "ai-agent"}, None, + {"ai_session_id": "current-session", "ai_client": "ai-agent"}), + ({}, "env-session", {"ai_session_id": "env-session"}), + ({"ai_session_id": "explicit-session"}, "env-session", {"ai_session_id": "explicit-session"}), + ({"ai_session_id": "", "ai_client": "ai-agent"}, "env-session", + {"ai_session_id": "env-session", "ai_client": "ai-agent"}), +]) +def test_redact_actor_markers(org, monkeypatch, markers, env_session, expected): + if env_session is not None: + monkeypatch.setenv("LC_AI_SESSION_ID", env_session) + response = {"event": {"redacted": True}, "images_pending_deletion": 3} + org.client.request.return_value = response + assert Cases(org).redact_note(42, "evt-1", **markers) == response + args, kwargs = org.client.request.call_args + assert args == ("POST", "api/v1/cases/42/notes/evt-1/redact") + assert kwargs["query_params"] == {"oid": "test-oid"} + assert kwargs["alt_root"] == "https://cases-staging.limacharlie.io" + assert kwargs["content_type"] == "application/json" + assert json.loads(kwargs["raw_body"]) == expected + + +@pytest.mark.parametrize("markers", [{}, {"ai_session_id": "", "ai_client": ""}]) +def test_redact_without_actor_markers_keeps_no_body(org, markers): + Cases(org).redact_note(42, "evt-1", **markers) + kwargs = org.client.request.call_args.kwargs + assert "raw_body" not in kwargs + assert "content_type" not in kwargs + + +def test_cli_redaction_inherits_current_actor_session(org, monkeypatch): + monkeypatch.setenv("LC_AI_SESSION_ID", "current-cli-session") + org.client.request.return_value = { + "event": {"redacted": True}, "images_pending_deletion": 1, + } + with patch("limacharlie.commands.case_cmd.Client", return_value=org.client), \ + patch("limacharlie.commands.case_cmd.Organization", return_value=org): + result = invoke("redact-note", ["--confirm"]) + assert result.exit_code == 0, result.output + assert json.loads(org.client.request.call_args.kwargs["raw_body"]) == { + "ai_session_id": "current-cli-session", + } + assert json.loads(result.output)["images_pending_deletion"] == 1 + + +@pytest.mark.parametrize("markers,env_session,expected", [ + ({"ai_session_id": "current-session"}, None, {"ai_session_id": "current-session"}), + ({"ai_client": "outside-ai-client"}, None, {"ai_client": "outside-ai-client"}), + ({"ai_session_id": "current-session", "ai_client": "ai-agent"}, None, + {"ai_session_id": "current-session", "ai_client": "ai-agent"}), + ({}, "env-session", {"ai_session_id": "env-session"}), + ({"ai_session_id": "explicit-session"}, "env-session", {"ai_session_id": "explicit-session"}), + ({"ai_session_id": "", "ai_client": "ai-agent"}, "env-session", + {"ai_session_id": "env-session", "ai_client": "ai-agent"}), +]) +def test_edit_actor_markers(org, monkeypatch, markers, env_session, expected): + if env_session is not None: + monkeypatch.setenv("LC_AI_SESSION_ID", env_session) + response = {"event": {"event_id": "evt-1"}} + org.client.request.return_value = response + assert Cases(org).edit_note(42, "evt-1", "replacement", **markers) == response + args, kwargs = org.client.request.call_args + assert args == ("PUT", "api/v1/cases/42/notes/evt-1/content") + assert kwargs["query_params"] == {"oid": "test-oid"} + assert kwargs["alt_root"] == "https://cases-staging.limacharlie.io" + assert kwargs["content_type"] == "application/json" + assert json.loads(kwargs["raw_body"]) == {"content": "replacement", **expected} + + +@pytest.mark.parametrize("markers", [{}, {"ai_session_id": "", "ai_client": ""}]) +def test_edit_without_actor_markers_keeps_content_only(org, markers): + Cases(org).edit_note(42, "evt-1", "replacement", **markers) + assert json.loads(org.client.request.call_args.kwargs["raw_body"]) == { + "content": "replacement", + } + + +def test_cli_edit_inherits_current_actor_session(org, monkeypatch): + monkeypatch.setenv("LC_AI_SESSION_ID", "current-editor-session") + org.client.request.return_value = {"event": {"event_id": "evt-1"}} + with patch("limacharlie.commands.case_cmd.Client", return_value=org.client), \ + patch("limacharlie.commands.case_cmd.Organization", return_value=org): + result = invoke("edit-note", ["--content", " replacement\n"]) + assert result.exit_code == 0, result.output + args, kwargs = org.client.request.call_args + assert args == ("PUT", "api/v1/cases/42/notes/evt-1/content") + assert kwargs["query_params"] == {"oid": "test-oid"} + assert kwargs["alt_root"] == "https://cases-staging.limacharlie.io" + assert json.loads(kwargs["raw_body"]) == { + "content": "replacement", "ai_session_id": "current-editor-session", + } + assert json.loads(result.output) == {"event": {"event_id": "evt-1"}}