Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions doc/cli/other-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions limacharlie/commands/case_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <EID> --content "Corrected notes"
Expand All @@ -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
Expand Down
42 changes: 40 additions & 2 deletions limacharlie/sdk/cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand All @@ -247,25 +257,46 @@ 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.
Repeating this call retries pending image deletion. Requires
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.
Expand All @@ -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(
Expand Down
99 changes: 98 additions & 1 deletion tests/unit/test_case_note_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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"}}
Loading