kanban-core 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kanban/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """Public API for the kanban business module."""
2
+
3
+ from .exceptions import (
4
+ ConcurrencyError,
5
+ InvalidPersistenceStateError,
6
+ KanbanError,
7
+ NotFoundError,
8
+ PermissionError,
9
+ ValidationError,
10
+ )
11
+ from .facade import BoardView, CardView, ColumnView, KanbanFacade
12
+
13
+ __version__ = "2.1.0"
14
+
15
+ __all__ = [
16
+ "BoardView",
17
+ "CardView",
18
+ "ColumnView",
19
+ "ConcurrencyError",
20
+ "InvalidPersistenceStateError",
21
+ "KanbanError",
22
+ "KanbanFacade",
23
+ "NotFoundError",
24
+ "PermissionError",
25
+ "ValidationError",
26
+ ]
kanban/events.py ADDED
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator, Mapping
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass, field, fields, is_dataclass
6
+ from datetime import datetime, timedelta
7
+ from enum import Enum
8
+ from typing import Any, Protocol
9
+
10
+ # ============= EVENTS SYSTEM =============
11
+
12
+
13
+ class EventRegistry:
14
+ """Singleton-by-explicit-dependency; avoids module-global state."""
15
+
16
+ def __init__(self, builtin: dict[str, str] | None = None):
17
+ self._events: dict[str, str] = dict(builtin) if builtin is not None else {}
18
+
19
+ def register(self, code: str, description: str) -> None:
20
+ if code in self._events:
21
+ raise ValueError(f"Event {code!r} already registered")
22
+ self._events[code] = description
23
+
24
+ def __contains__(self, code: str) -> bool:
25
+ return code in self._events
26
+
27
+ def __iter__(self) -> Iterator[str]:
28
+ yield from self._events.keys()
29
+
30
+
31
+ builtin_events = {
32
+ "board.created": "Board created",
33
+ "board.updated": "Board updated",
34
+ "board.deleted": "Board deleted",
35
+ "column.created": "Column created",
36
+ "column.updated": "Column updated",
37
+ "column.deleted": "Column deleted",
38
+ "column.moved": "Column moved",
39
+ "card.created": "Card created",
40
+ "card.updated": "Card updated",
41
+ "card.deleted": "Card deleted",
42
+ "card.moved": "Card moved",
43
+ "card.blocked": "Card blocked",
44
+ "card.unblocked": "Card unblocked",
45
+ "wip.limit.reached": "WIP limit reached",
46
+ "workflow.transition": "Workflow transition",
47
+ "automation.triggered": "Automation triggered",
48
+ "automation.failed": "Automation failed",
49
+ }
50
+
51
+ CORE_EVENTS = EventRegistry(builtin_events)
52
+ CARD_MOVED = "card.moved"
53
+ CARD_UPDATED = "card.updated"
54
+ CARD_CREATED = "card.created"
55
+ CARD_DELETED = "card.deleted"
56
+ COLUMN_UPDATED = "column.updated"
57
+ COLUMN_CREATED = "column.created"
58
+ COLUMN_DELETED = "column.deleted"
59
+ BOARD_CREATED = "board.created"
60
+ BOARD_UPDATED = "board.updated"
61
+ BOARD_DELETED = "board.deleted"
62
+ CARD_BLOCKED = "card.blocked"
63
+ CARD_UNBLOCKED = "card.unblocked"
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Event:
68
+ """Immutable event object. Handlers receive a copy."""
69
+
70
+ type: str
71
+ entity_id: str
72
+ entity_type: str
73
+ id: str
74
+ timestamp: datetime
75
+ data: dict[str, Any] = field(default_factory=dict)
76
+ user_id: str | None = None
77
+ schema_version: int = 1
78
+ correlation_id: str | None = None
79
+ causation_id: str | None = None
80
+ aggregate_version: int | None = None
81
+
82
+ def __post_init__(self) -> None:
83
+ # A frozen dataclass does not make nested dictionaries immutable. Detach the
84
+ # payload from the caller so later mutations cannot rewrite event history.
85
+ object.__setattr__(self, "data", deepcopy(self.data))
86
+
87
+ def to_dict(self) -> dict[str, Any]:
88
+ """Converts the event to a dictionary with serializable types."""
89
+
90
+ def _serialize_value(v: Any) -> Any:
91
+ if isinstance(v, set):
92
+ return sorted((_serialize_value(item) for item in v), key=repr)
93
+ if isinstance(v, list | tuple):
94
+ return [_serialize_value(item) for item in v]
95
+ if isinstance(v, Mapping):
96
+ return {key: _serialize_value(value) for key, value in v.items()}
97
+ if isinstance(v, datetime):
98
+ return v.isoformat()
99
+ if isinstance(v, timedelta):
100
+ return v.total_seconds()
101
+ if isinstance(v, Enum):
102
+ return _serialize_value(v.value)
103
+ if is_dataclass(v) and not isinstance(v, type):
104
+ return {
105
+ item.name: _serialize_value(getattr(v, item.name))
106
+ for item in fields(v)
107
+ }
108
+ return v
109
+
110
+ return {
111
+ "id": self.id,
112
+ "type": self.type,
113
+ "entity_id": self.entity_id,
114
+ "entity_type": self.entity_type,
115
+ "timestamp": self.timestamp.isoformat(),
116
+ "user_id": self.user_id,
117
+ "schema_version": self.schema_version,
118
+ "correlation_id": self.correlation_id,
119
+ "causation_id": self.causation_id,
120
+ "aggregate_version": self.aggregate_version,
121
+ "data": _serialize_value(self.data),
122
+ }
123
+
124
+
125
+ class EventPublisher(Protocol):
126
+ """Outbound port used only after the aggregate commit succeeds."""
127
+
128
+ def publish(self, event: Event) -> None: ...
kanban/exceptions.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from collections.abc import Callable
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Protocol, runtime_checkable
7
+
8
+ # ============= TYPE ALIASES & EXCEPTIONS =============
9
+
10
+ Validator = Callable[[Any], None]
11
+
12
+
13
+ class KanbanError(Exception):
14
+ """Base exception for all library errors."""
15
+
16
+
17
+ class ValidationError(KanbanError):
18
+ """For business rule violations."""
19
+
20
+
21
+ class PermissionError(KanbanError):
22
+ """For authorization failures."""
23
+
24
+
25
+ class NotFoundError(KanbanError):
26
+ """For when an entity cannot be found."""
27
+
28
+
29
+ class ConcurrencyError(KanbanError):
30
+ """For optimistic locking failures (lost updates)."""
31
+
32
+
33
+ class InvalidPersistenceStateError(KanbanError):
34
+ """Persisted state violates business-module invariants."""
35
+
36
+
37
+ @runtime_checkable
38
+ class Clock(Protocol):
39
+ def __call__(self) -> datetime: ...
40
+
41
+
42
+ def utc_clock() -> datetime:
43
+ return datetime.now(timezone.utc)
44
+
45
+
46
+ @runtime_checkable
47
+ class IDFactory(Protocol):
48
+ def __call__(self) -> str: ...
49
+
50
+
51
+ def uuid4_factory() -> str:
52
+ return str(uuid.uuid4())
kanban/facade.py ADDED
@@ -0,0 +1,262 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from .exceptions import Clock, IDFactory, utc_clock, uuid4_factory
8
+ from .models import Board, Card, Column
9
+ from .permissions import Permission, PermissionPolicy, SimplePermissionPolicy
10
+ from .policies import WorkflowPolicy
11
+ from .repositories import KanbanRepository, restore_board
12
+ from .services import (
13
+ BoardAdminService,
14
+ CardSearcher,
15
+ CardWorkflowService,
16
+ InMemoryCardSearcher,
17
+ SyncAutomationRunner,
18
+ _KanbanServiceCore,
19
+ )
20
+ from .spi.automation import AutomationRunner
21
+ from .unit_of_work import KanbanUnitOfWork
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class CardView:
26
+ id: str
27
+ column_id: str
28
+ title: str
29
+ description: str
30
+ position: int
31
+ blocked_by: frozenset[str]
32
+ blocks: frozenset[str]
33
+ assignee_ids: frozenset[str]
34
+ due_date: datetime | None
35
+ size: float | None
36
+ archived: bool
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ColumnView:
41
+ id: str
42
+ name: str
43
+ position: int
44
+ card_ids: tuple[str, ...]
45
+ color: str | None
46
+ wip_limit: int | None
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class BoardView:
51
+ id: str
52
+ version: int
53
+ name: str
54
+ description: str
55
+ columns: tuple[ColumnView, ...]
56
+ cards: tuple[CardView, ...]
57
+ created_at: datetime | None
58
+ updated_at: datetime | None
59
+ owner_id: str | None
60
+ team_ids: frozenset[str]
61
+
62
+ def column_named(self, name: str) -> ColumnView | None:
63
+ return next((column for column in self.columns if column.name == name), None)
64
+
65
+ def card(self, card_id: str) -> CardView | None:
66
+ return next((card for card in self.cards if card.id == card_id), None)
67
+
68
+
69
+ def _card_view(card: Card, board: Board) -> CardView:
70
+ column = board.column_for_card(card.id)
71
+ return CardView(
72
+ id=card.id,
73
+ column_id=column.id,
74
+ title=card.title,
75
+ description=card.description,
76
+ position=column.card_ids.index(card.id),
77
+ blocked_by=frozenset(card.blocked_by),
78
+ blocks=frozenset(card.blocks),
79
+ assignee_ids=frozenset(card.assignee_ids),
80
+ due_date=card.due_date,
81
+ size=card.size,
82
+ archived=card.archived,
83
+ )
84
+
85
+
86
+ def _column_view(column: Column) -> ColumnView:
87
+ return ColumnView(
88
+ id=column.id,
89
+ name=column.name,
90
+ position=column.position,
91
+ card_ids=tuple(column.card_ids),
92
+ color=column.color,
93
+ wip_limit=column.policy.wip_limit,
94
+ )
95
+
96
+
97
+ def _board_view(board: Board) -> BoardView:
98
+ cards = [
99
+ card
100
+ for column in board.get_columns_ordered()
101
+ for card in board.get_cards_in_column(column.id)
102
+ ]
103
+ return BoardView(
104
+ id=board.id,
105
+ version=board.version,
106
+ name=board.name,
107
+ description=board.description,
108
+ columns=tuple(_column_view(column) for column in board.get_columns_ordered()),
109
+ cards=tuple(_card_view(card, board) for card in cards),
110
+ created_at=board.created_at,
111
+ updated_at=board.updated_at,
112
+ owner_id=board.owner_id,
113
+ team_ids=frozenset(board.team_ids),
114
+ )
115
+
116
+
117
+ class KanbanFacade:
118
+ """The complete public business capability surface for kanban-core."""
119
+
120
+ def __init__(
121
+ self,
122
+ repository: KanbanRepository,
123
+ unit_of_work: KanbanUnitOfWork,
124
+ permission_checker: PermissionPolicy | None = None,
125
+ searcher: CardSearcher | None = None,
126
+ automation_runner: AutomationRunner | None = None,
127
+ clock: Clock | None = None,
128
+ id_factory: IDFactory | None = None,
129
+ workflow_policy: WorkflowPolicy | None = None,
130
+ ) -> None:
131
+ core = _KanbanServiceCore(
132
+ repository=repository,
133
+ unit_of_work=unit_of_work,
134
+ permission_checker=permission_checker or SimplePermissionPolicy(),
135
+ searcher=searcher or InMemoryCardSearcher(),
136
+ automation_runner=automation_runner or SyncAutomationRunner(),
137
+ clock=clock or utc_clock,
138
+ id_factory=id_factory or uuid4_factory,
139
+ workflow_policy=workflow_policy,
140
+ )
141
+ self._core = core
142
+ self._admin = BoardAdminService(core)
143
+ self._cards = CardWorkflowService(core)
144
+
145
+ def create_board(
146
+ self, user_id: str, name: str, columns: list[str] | None = None
147
+ ) -> BoardView:
148
+ return _board_view(self._admin.create_board(name, user_id, columns))
149
+
150
+ def get_board(self, user_id: str, board_id: str) -> BoardView:
151
+ board = self._core.get_board_and_check_permission(
152
+ board_id, user_id, Permission.VIEW_BOARD
153
+ )
154
+ return _board_view(board)
155
+
156
+ def list_boards(self, user_id: str) -> list[BoardView]:
157
+ boards = [restore_board(item) for item in self._core.repository.list_all()]
158
+ return [
159
+ _board_view(board)
160
+ for board in boards
161
+ if self._core.is_allowed(user_id, board, Permission.VIEW_BOARD)
162
+ ]
163
+
164
+ def update_board(self, user_id: str, board_id: str, **changes: Any) -> BoardView:
165
+ return _board_view(self._admin.update_board(board_id, user_id, **changes))
166
+
167
+ def delete_board(self, user_id: str, board_id: str) -> None:
168
+ self._admin.delete_board(board_id, user_id)
169
+
170
+ def add_column(
171
+ self,
172
+ user_id: str,
173
+ board_id: str,
174
+ name: str,
175
+ position: int | None = None,
176
+ ) -> ColumnView:
177
+ return _column_view(
178
+ self._admin.create_column(board_id, user_id, name, position)
179
+ )
180
+
181
+ def update_column(
182
+ self, user_id: str, board_id: str, column_id: str, **changes: Any
183
+ ) -> ColumnView:
184
+ return _column_view(
185
+ self._admin.update_column(board_id, column_id, user_id, **changes)
186
+ )
187
+
188
+ def delete_column(self, user_id: str, board_id: str, column_id: str) -> None:
189
+ self._admin.delete_column(board_id, column_id, user_id)
190
+
191
+ def add_card(
192
+ self,
193
+ user_id: str,
194
+ board_id: str,
195
+ column_id: str,
196
+ title: str,
197
+ **fields: Any,
198
+ ) -> CardView:
199
+ card = self._cards.create_card(board_id, column_id, title, user_id, **fields)
200
+ board = self._core.get_board_and_check_permission(
201
+ board_id, user_id, Permission.VIEW_BOARD
202
+ )
203
+ return _card_view(card, board)
204
+
205
+ def update_card(
206
+ self, user_id: str, board_id: str, card_id: str, **changes: Any
207
+ ) -> CardView:
208
+ card = self._cards.update_card(board_id, card_id, user_id, **changes)
209
+ board = self._core.get_board_and_check_permission(
210
+ board_id, user_id, Permission.VIEW_BOARD
211
+ )
212
+ return _card_view(card, board)
213
+
214
+ def move_card(
215
+ self,
216
+ user_id: str,
217
+ board_id: str,
218
+ card_id: str,
219
+ to_column_id: str,
220
+ position: int | None = None,
221
+ ) -> CardView:
222
+ card = self._cards.move_card(board_id, card_id, to_column_id, position, user_id)
223
+ board = self._core.get_board_and_check_permission(
224
+ board_id, user_id, Permission.VIEW_BOARD
225
+ )
226
+ return _card_view(card, board)
227
+
228
+ def delete_card(self, user_id: str, board_id: str, card_id: str) -> None:
229
+ self._cards.delete_card(board_id, card_id, user_id)
230
+
231
+ def block_card(
232
+ self,
233
+ user_id: str,
234
+ board_id: str,
235
+ card_to_block_id: str,
236
+ blocking_card_id: str,
237
+ ) -> None:
238
+ self._cards.block_card_dependency(
239
+ board_id, blocking_card_id, card_to_block_id, user_id
240
+ )
241
+
242
+ def unblock_card(
243
+ self,
244
+ user_id: str,
245
+ board_id: str,
246
+ card_to_unblock_id: str,
247
+ blocking_card_id: str,
248
+ ) -> None:
249
+ self._cards.unblock_card_dependency(
250
+ board_id, blocking_card_id, card_to_unblock_id, user_id
251
+ )
252
+
253
+ def search_cards(
254
+ self, user_id: str, board_id: str, **filters: Any
255
+ ) -> list[CardView]:
256
+ board = self._core.get_board_and_check_permission(
257
+ board_id, user_id, Permission.VIEW_BOARD
258
+ )
259
+ card_ids = self._core.searcher.search(
260
+ self._core.search_context(board), **filters
261
+ )
262
+ return [_card_view(board.cards[card_id], board) for card_id in card_ids]
kanban/models.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from .exceptions import ValidationError
8
+
9
+ # ============= CORE DOMAIN MODELS =============
10
+
11
+
12
+ @dataclass
13
+ class Card:
14
+ """Core card/item entity."""
15
+
16
+ id: str
17
+ title: str = ""
18
+ description: str = ""
19
+ blocked_by: set[str] = field(default_factory=set)
20
+ blocks: set[str] = field(default_factory=set)
21
+ assignee_ids: set[str] = field(default_factory=set)
22
+ due_date: datetime | None = None
23
+ size: float | None = None
24
+ archived: bool = False
25
+
26
+ def is_blocked(self) -> bool:
27
+ return bool(self.blocked_by)
28
+
29
+ def to_dict(self) -> dict[str, Any]:
30
+ return {
31
+ "id": self.id,
32
+ "title": self.title,
33
+ "description": self.description,
34
+ "blocked_by": sorted(self.blocked_by),
35
+ "blocks": sorted(self.blocks),
36
+ "assignee_ids": sorted(self.assignee_ids),
37
+ "due_date": self.due_date.isoformat() if self.due_date else None,
38
+ "size": self.size,
39
+ "archived": self.archived,
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class ColumnPolicy:
45
+ """Rules for column behavior."""
46
+
47
+ wip_limit: int | None = None
48
+
49
+
50
+ @dataclass
51
+ class Column:
52
+ """Board column/list."""
53
+
54
+ id: str
55
+ board_id: str
56
+ name: str = ""
57
+ position: int = 0
58
+ policy: ColumnPolicy = field(default_factory=ColumnPolicy)
59
+ card_ids: list[str] = field(default_factory=list)
60
+ color: str | None = None
61
+
62
+ def can_accept_card(self, is_new_entry: bool) -> tuple[bool, str | None]:
63
+ if (
64
+ is_new_entry
65
+ and self.policy.wip_limit is not None
66
+ and len(self.card_ids) >= self.policy.wip_limit
67
+ ):
68
+ return False, f"WIP limit of {self.policy.wip_limit} would be exceeded."
69
+ return True, None
70
+
71
+
72
+ @dataclass
73
+ class Board:
74
+ """Kanban board entity"""
75
+
76
+ id: str
77
+ version: int = 1
78
+ name: str = ""
79
+ description: str = ""
80
+ columns: dict[str, Column] = field(default_factory=dict)
81
+ cards: dict[str, Card] = field(default_factory=dict)
82
+ created_at: datetime | None = None
83
+ updated_at: datetime | None = None
84
+ owner_id: str | None = None
85
+ team_ids: set[str] = field(default_factory=set)
86
+
87
+ def get_columns_ordered(self) -> list[Column]:
88
+ return sorted(self.columns.values(), key=lambda c: c.position)
89
+
90
+ def column_for_card(self, card_id: str) -> Column:
91
+ for column in self.columns.values():
92
+ if card_id in column.card_ids:
93
+ return column
94
+ raise ValidationError(f"Card '{card_id}' is not assigned to a column")
95
+
96
+ def get_cards_in_column(self, column_id: str) -> list[Card]:
97
+ column = self.columns.get(column_id)
98
+ if not column:
99
+ return []
100
+ return [
101
+ self.cards[card_id] for card_id in column.card_ids if card_id in self.cards
102
+ ]
kanban/permissions.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum, auto
5
+ from typing import Protocol
6
+
7
+
8
+ class Permission(Enum):
9
+ VIEW_BOARD = auto()
10
+ EDIT_BOARD = auto()
11
+ DELETE_BOARD = auto()
12
+ CREATE_CARD = auto()
13
+ EDIT_CARD = auto()
14
+ MOVE_CARD = auto()
15
+ DELETE_CARD = auto()
16
+ CREATE_COLUMN = auto()
17
+ EDIT_COLUMN = auto()
18
+ DELETE_COLUMN = auto()
19
+ MANAGE_USERS = auto()
20
+ MANAGE_AUTOMATION = auto()
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class PermissionContext:
25
+ board_id: str
26
+ owner_id: str
27
+ team_ids: frozenset[str]
28
+
29
+
30
+ class PermissionPolicy(Protocol):
31
+ def has_permission(
32
+ self, user_id: str, context: PermissionContext, permission: Permission
33
+ ) -> bool: ...
34
+
35
+
36
+ class SimplePermissionPolicy:
37
+ DEFAULT_MEMBER_PERMISSIONS = {
38
+ Permission.VIEW_BOARD,
39
+ Permission.CREATE_CARD,
40
+ Permission.EDIT_CARD,
41
+ Permission.MOVE_CARD,
42
+ Permission.DELETE_CARD,
43
+ Permission.CREATE_COLUMN,
44
+ Permission.EDIT_COLUMN,
45
+ Permission.DELETE_COLUMN,
46
+ }
47
+
48
+ def __init__(self, member_permissions: set[Permission] | None = None):
49
+ self.member_permissions = (
50
+ set(member_permissions)
51
+ if member_permissions is not None
52
+ else set(self.DEFAULT_MEMBER_PERMISSIONS)
53
+ )
54
+
55
+ def has_permission(
56
+ self, user_id: str, context: PermissionContext, permission: Permission
57
+ ) -> bool:
58
+ if not user_id:
59
+ return False
60
+ if context.owner_id == user_id:
61
+ return True
62
+ if user_id in context.team_ids:
63
+ return permission in self.member_permissions
64
+ return False
kanban/policies.py ADDED
@@ -0,0 +1,66 @@
1
+ """Public, read-only extension points for organization-specific workflow rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Protocol
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class CardPolicyContext:
12
+ """A detached card snapshot supplied to an external workflow policy."""
13
+
14
+ board_id: str
15
+ user_id: str
16
+ card_id: str
17
+ column_id: str
18
+ title: str
19
+ description: str
20
+ assignee_ids: frozenset[str]
21
+ due_date: datetime | None
22
+ size: float | None
23
+ archived: bool
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class CardMovePolicyContext:
28
+ """The proposed card transition, before the aggregate is mutated."""
29
+
30
+ card: CardPolicyContext
31
+ from_column_id: str
32
+ to_column_id: str
33
+ position: int
34
+
35
+
36
+ class WorkflowPolicy(Protocol):
37
+ """Optional policy port for deployment-specific card lifecycle rules.
38
+
39
+ Implementations validate only. They must not persist data, publish events, or
40
+ mutate the supplied contexts. A rejected operation should raise
41
+ ``kanban.ValidationError`` (or a subclass).
42
+ """
43
+
44
+ def validate_create(self, context: CardPolicyContext) -> None: ...
45
+
46
+ def validate_update(self, context: CardPolicyContext) -> None: ...
47
+
48
+ def validate_move(self, context: CardMovePolicyContext) -> None: ...
49
+
50
+ def validate_delete(self, context: CardPolicyContext) -> None: ...
51
+
52
+
53
+ class AllowAllWorkflowPolicy:
54
+ """Default policy; structural domain invariants still apply."""
55
+
56
+ def validate_create(self, context: CardPolicyContext) -> None:
57
+ pass
58
+
59
+ def validate_update(self, context: CardPolicyContext) -> None:
60
+ pass
61
+
62
+ def validate_move(self, context: CardMovePolicyContext) -> None:
63
+ pass
64
+
65
+ def validate_delete(self, context: CardPolicyContext) -> None:
66
+ pass