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
158 changes: 158 additions & 0 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4656,6 +4656,46 @@
"title": "DiscardSubjectRequest",
"type": "object"
},
"DismissEventInReactionRequest": {
"description": "Body for `POST /agent/reactions/{subscriber_name}/dismiss-event`.",
"properties": {
"event_id": {
"description": "The event_id the operator wants the subscriber's bookmark advanced past. Read from the operator dashboard's wedged-bookmark surface (last_error_message + last_event_recorded_at on projection_bookmarks); pasting an arbitrary event_id risks rewinding the bookmark, which the decider catches via the lexicographic (transaction_id, position) check.",
"format": "uuid",
"title": "Event Id",
"type": "string"
},
"reason": {
"description": "Free-form reason the operator gives for the dismissal (1-500 chars after trimming). Captured verbatim into DecisionRegistered.reasoning for audit. Operationally: 'cannot deserialize this event_type since the rename, advancing past' / 'LLM returned hallucinated context, manual retry tomorrow' etc.",
"maxLength": 500,
"minLength": 1,
"title": "Reason",
"type": "string"
}
},
"required": [
"event_id",
"reason"
],
"title": "DismissEventInReactionRequest",
"type": "object"
},
"DismissEventInReactionResponse": {
"description": "Body returned on successful dismissal.",
"properties": {
"decision_id": {
"description": "The Decision audit record's id.",
"format": "uuid",
"title": "Decision Id",
"type": "string"
}
},
"required": [
"decision_id"
],
"title": "DismissEventInReactionResponse",
"type": "object"
},
"DismountSubjectRequest": {
"description": "Body for `POST /subjects/{subject_id}/dismount`.",
"properties": {
Expand Down Expand Up @@ -11835,6 +11875,124 @@
]
}
},
"/agent/reactions/{subscriber_name}/dismiss-event": {
"post": {
"operationId": "post_agent_reactions_dismiss_event_agent_reactions__subscriber_name__dismiss_event_post",
"parameters": [
{
"description": "Name of the subscriber (matches `Reaction.name`).",
"in": "path",
"name": "subscriber_name",
"required": true,
"schema": {
"description": "Name of the subscriber (matches `Reaction.name`).",
"maxLength": 200,
"minLength": 1,
"title": "Subscriber Name",
"type": "string"
}
},
{
"description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.",
"in": "header",
"name": "X-Principal-Id",
"required": false,
"schema": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.",
"title": "X-Principal-Id"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DismissEventInReactionRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DismissEventInReactionResponse"
}
}
},
"description": "Successful Response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Domain invariant violated: empty / oversize reason (InvalidDismissalReasonError)."
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Authorize port denied the command."
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "No projection_bookmarks row for the named subscriber (SubscriberBookmarkNotFoundError) OR no event row for the supplied event_id (DismissalEventNotFoundError)."
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "The bookmark is already at or past the target event (EventAlreadyDismissedError); refresh the dashboard and re-evaluate before retrying."
},
"422": {
"description": "Path parameter or request body failed schema validation."
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "The deployment has no Postgres pool (DismissalRequiresPostgresError); the slice is a no-op without projection_bookmarks. Production deployments never see this."
}
},
"summary": "Dismiss a poison event on a Reaction's bookmark",
"tags": [
"agent"
]
}
},
"/agents": {
"post": {
"operationId": "post_agents_agents_post",
Expand Down
22 changes: 18 additions & 4 deletions apps/api/src/cora/agent/_subscribers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,24 @@
via the `promote_caution_proposal` slice.

Both subscribers run concurrently and INDEPENDENTLY in the
projection worker. Per [[project-caution-drafter-design]] Q4 lock:
DO NOT widen the subscriber framework. Named widening
triggers (3rd subscriber / >50ms blocking / first cross-subscriber
ordering dependency) documented in the design memo.
projection worker. Both classify as `Reaction` (the public sibling
to `Projection`) and pin `batch_size = 1` on the class so the
worker bounds the bookmark transaction to a single LLM round-trip.

## Subscriber framework widening status

Per [[project-caution-drafter-design]] Q4 lock the original
widening triggers were "3rd subscriber / >50ms blocking / first
cross-subscriber ordering dependency". The >50ms-blocking trigger
FIRED at first-ship (each LLM call is 5-15 s), and the framework
widened minimally: the `Reaction` Protocol was added as the sibling
to `Projection` and per-subscriber `batch_size` enforcement landed
so the docstring's `batch_size=1` claim became real.

Further widening (separate `ReactionWorker` with its own pool
budget, operator escape-hatch for wedged bookmarks via
`dismiss_event_in_reaction`) stays deferred behind the next named
triggers: 3rd Reaction OR first wedged-bookmark incident.
"""

from __future__ import annotations
Expand Down
80 changes: 80 additions & 0 deletions apps/api/src/cora/agent/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,83 @@ def __init__(
self.decision_id = decision_id
self.actor_id = actor_id
self.observed_kind = observed_kind


class InvalidDismissalReasonError(Exception):
"""The `dismiss_event_in_reaction` reason failed validation (empty,
too long, or otherwise out of bounds). Maps to HTTP 400."""

def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason


class SubscriberBookmarkNotFoundError(Exception):
"""No `projection_bookmarks` row matches the supplied subscriber
name. Either the subscriber is misspelled, or its migration
hasn't landed in this deployment. Maps to HTTP 404."""

def __init__(self, subscriber_name: str) -> None:
super().__init__(
f"No projection_bookmarks row for subscriber {subscriber_name!r}. "
"Check the spelling against the registered Reactions / Projections, "
"or verify the subscriber's migration has been applied."
)
self.subscriber_name = subscriber_name


class DismissalEventNotFoundError(Exception):
"""The supplied event_id does not exist in the events table.
Either the operator pasted the wrong id, or the event was
canonicalized away by a maintenance migration. Maps to HTTP 404."""

def __init__(self, event_id: UUID) -> None:
super().__init__(
f"No event row for event_id {event_id}. The operator dashboard "
"should be the source of truth for the wedged event's id."
)
self.event_id = event_id


class EventAlreadyDismissedError(Exception):
"""The bookmark is already at or past the target event, so the
dismissal would be a no-op (or worse, would rewind the cursor).
Surfaces as 409 so the operator can refresh the dashboard and
re-evaluate."""

def __init__(
self,
*,
subscriber_name: str,
event_id: UUID,
bookmark_transaction_id: int,
bookmark_position: int,
event_transaction_id: int,
event_position: int,
) -> None:
super().__init__(
f"Subscriber {subscriber_name!r} bookmark "
f"({bookmark_transaction_id}, {bookmark_position}) is at or past "
f"event {event_id} cursor ({event_transaction_id}, "
f"{event_position}); dismissal would be a no-op or rewind."
)
self.subscriber_name = subscriber_name
self.event_id = event_id
self.bookmark_transaction_id = bookmark_transaction_id
self.bookmark_position = bookmark_position
self.event_transaction_id = event_transaction_id
self.event_position = event_position


class DismissalRequiresPostgresError(Exception):
"""`dismiss_event_in_reaction` requires a Postgres pool because the
bookmark advance is a SQL UPDATE on `projection_bookmarks`. The
in-memory test adapter has no such table; raise so the operator
sees a clear "not in this configuration" instead of a stale-cache
success. Maps to HTTP 503."""

def __init__(self) -> None:
super().__init__(
"dismiss_event_in_reaction requires a Postgres pool; the "
"in-memory adapter has no projection_bookmarks table to advance."
)
9 changes: 9 additions & 0 deletions apps/api/src/cora/agent/features/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,19 @@
idempotency-wrapped; Pattern C)

Also: deprecate's source set widens to include Suspended.

Reaction recovery:
- `dismiss_event_in_reaction` (operator-triggered atomic bookmark
advance + DecisionRegistered audit
via append_streams(conn=); recovers
a wedged Reaction bookmark without
SSH access to projection_bookmarks)
"""

from cora.agent.features import (
define_agent,
deprecate_agent,
dismiss_event_in_reaction,
get_agent,
grant_tool_to_agent,
promote_caution_proposal,
Expand All @@ -52,6 +60,7 @@
__all__ = [
"define_agent",
"deprecate_agent",
"dismiss_event_in_reaction",
"get_agent",
"grant_tool_to_agent",
"promote_caution_proposal",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Vertical slice for the `DismissEventInReaction` command.

Operator escape-hatch for a wedged Reaction bookmark: advances the
subscriber past one poison event AND records the dismissal as a
`DecisionRegistered` audit row in the same Postgres transaction.

See [[project-reaction-protocol-design]] (commit 1 of this slice) for
why Reactions are the failure-mode this slice targets. Operationally:
the on-call playbook is "page → dismiss-event with reason → debug
tomorrow" instead of "page → SSH → psql → UPDATE projection_bookmarks."
"""

from cora.agent.features.dismiss_event_in_reaction import tool
from cora.agent.features.dismiss_event_in_reaction.command import (
DismissEventInReaction,
)
from cora.agent.features.dismiss_event_in_reaction.decider import (
DismissalContext,
decide,
)
from cora.agent.features.dismiss_event_in_reaction.handler import Handler, bind
from cora.agent.features.dismiss_event_in_reaction.route import router

__all__ = [
"DismissEventInReaction",
"DismissalContext",
"Handler",
"bind",
"decide",
"router",
"tool",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Operator command to dismiss a poison event from a Reaction's bookmark.

Operator-invoked recovery action: when a Reaction (e.g.,
`RunDebriefer`, `CautionDrafter`) wedges on a single event the
subscriber's `apply()` cannot process (poison event, schema drift,
unrecoverable LLM failure), the operator hits this slice to:

1. Advance the subscriber's `projection_bookmarks` row past the
event, and
2. Record the dismissal as an auditable `DecisionRegistered`
(`context = "ReactionDismissal"`, `choice = "EventDismissed"`)
so the operator action is preserved in the same audit log as
every other operator judgment call.

Both writes happen atomically inside a single Postgres transaction
(mirrors the `forget_actor` cross-store pattern).
"""

from dataclasses import dataclass
from uuid import UUID


@dataclass(frozen=True)
class DismissEventInReaction:
"""Advance `subscriber_name`'s bookmark past `event_id`; record a
Decision under operator identity.

Fields:
- `subscriber_name`: the bookmark `name` (matches the Reaction
/ Subscriber's `name` attribute). The slice queries
`projection_bookmarks WHERE name = subscriber_name`.
- `event_id`: the event the operator wants to dismiss. The slice
looks it up in the `events` table to resolve its
(transaction_id, position) cursor before advancing.
- `reason`: free-form text the operator supplies explaining the
dismissal. Carried verbatim into `DecisionRegistered.reasoning`
for audit; trimmed to the Decision aggregate's choice-length
bound.
"""

subscriber_name: str
event_id: UUID
reason: str
Loading
Loading