ktalk-cli 1.0.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.
ktalk_cli/store.py ADDED
@@ -0,0 +1,88 @@
1
+ """Централизованное машинное хранилище (ADR-013): корень, права, детекция
2
+ облачной синхронизации.
3
+
4
+ Companion-спека: `content/40-architecture/ADR-013-central-transcript-store-spec.md`.
5
+ Отдельный модуль от `registry.py` (грандфазер заморожен на 562 строках).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ _STORE_DIR_NAME = "ktalk"
15
+
16
+ # NFR-14: маркеры известных каталогов облачной синхронизации. Сегментация по
17
+ # границе каталога, не substring — `MyDropboxBackup/` не должен ложно
18
+ # сработать на `Dropbox` (ADR-013-spec, boundary).
19
+ _SYNC_MULTI_SEGMENT_MARKERS: tuple[tuple[str, str], ...] = (
20
+ ("Library", "Mobile Documents"),
21
+ ("Library", "CloudStorage"),
22
+ )
23
+ _SYNC_SINGLE_SEGMENT_MARKERS: tuple[str, ...] = (
24
+ "Dropbox",
25
+ "Google Drive",
26
+ "OneDrive",
27
+ "Nextcloud",
28
+ "ownCloud",
29
+ )
30
+
31
+
32
+ def resolve_store_root() -> Path:
33
+ """Резолвит и создаёт корень централизованного хранилища (ADR-013 §1).
34
+
35
+ `${XDG_DATA_HOME:-$HOME/.local/share}/ktalk`. `$XDG_DATA_HOME=""` (пустая
36
+ строка) трактуется как «не задано», не как путь `""` (boundary case).
37
+
38
+ Права `0700` на каталог и, transitively, `0600` на файлы, создаваемые этим
39
+ процессом впоследствии (в т.ч. `registry.db` через `sqlite3.connect`) —
40
+ обеспечивается процессным `umask`, применённым здесь один раз, а не
41
+ ретроактивным `chmod` (NFR-15: «без окна между созданием и ограничением
42
+ прав»). Побочный эффект осознанный: с момента первого разрешения корня
43
+ хранилища процесс создаёт любые файлы владелец-only до конца своего цикла
44
+ жизни — это соответствует всей раскладке хранилища (`registry.db`, `-wal`,
45
+ `-shm`, `transcripts/`, `eval/reports/`), не только одному файлу.
46
+ """
47
+ xdg_data_home = os.environ.get("XDG_DATA_HOME") or None
48
+ if xdg_data_home:
49
+ base = Path(xdg_data_home)
50
+ else:
51
+ home = os.environ.get("HOME") or str(Path.home())
52
+ base = Path(home) / ".local" / "share"
53
+ root = base / _STORE_DIR_NAME
54
+ os.umask(0o077)
55
+ root.mkdir(parents=True, exist_ok=True)
56
+ # MAJ-03 (security review SEC-003): `mkdir(exist_ok=True)` не чинит режим уже
57
+ # существующего каталога — безусловный `chmod`, не только при первом создании,
58
+ # закрывает случай «каталог остался с ослабленными правами от более ранней
59
+ # версии/ручного создания/восстановления из бэкапа».
60
+ os.chmod(root, 0o700)
61
+ return root
62
+
63
+
64
+ def detect_sync_dir(path: str | Path) -> tuple[bool, str]:
65
+ """Эвристика NFR-14: путь внутри известного каталога облачной синхронизации.
66
+
67
+ Не платформенный API (нет надёжного), а сегментированная проверка частей
68
+ пути — не гарантия, известное ограничение (ADR-013, Consequences).
69
+ """
70
+ parts = [p.lower() for p in Path(path).parts]
71
+ for seg1, seg2 in _SYNC_MULTI_SEGMENT_MARKERS:
72
+ s1, s2 = seg1.lower(), seg2.lower()
73
+ for i in range(len(parts) - 1):
74
+ if parts[i] == s1 and parts[i + 1] == s2:
75
+ return True, f"путь внутри каталога облачной синхронизации ({seg1}/{seg2})"
76
+ for marker in _SYNC_SINGLE_SEGMENT_MARKERS:
77
+ if marker.lower() in parts:
78
+ return True, f"путь внутри каталога облачной синхронизации ({marker})"
79
+ return False, "путь вне известных каталогов облачной синхронизации"
80
+
81
+
82
+ def warn_if_sync_dir(path: str | Path) -> bool:
83
+ """NFR-14 AC-2: путь внутри каталога синхронизации -> предупреждение в
84
+ stderr, работа продолжается (не блокировка)."""
85
+ is_sync, reason = detect_sync_dir(path)
86
+ if is_sync:
87
+ print(f"Предупреждение: {reason}: {path}", file=sys.stderr)
88
+ return is_sync
@@ -0,0 +1,89 @@
1
+ """Миграция реестра в централизованное хранилище — явный обратимый шаг (NFR-12).
2
+
3
+ Companion-спека: `content/40-architecture/ADR-013-central-transcript-store-spec.md`,
4
+ «Контракт команды миграции». Отдельно от `ktalk migrate <vault>` (`registry.py`,
5
+ `migrate_from_vault`) — та импортирует markdown-архивы (ADR-002), эта копирует
6
+ существующий SQLite-реестр на новый путь. Read-only использование `registry.py`
7
+ (построчный дамп через `sqlite3.iterdump`), не расширяет его.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import shutil
14
+ import sqlite3
15
+ from datetime import date
16
+ from pathlib import Path
17
+
18
+
19
+ class MigrationVerificationError(Exception):
20
+ """Построчная сверка дампа источник<->копия не совпала — миграция отменена,
21
+ источник не тронут, целевой файл удалён (не остаётся частичной копией)."""
22
+
23
+
24
+ class MigrationTargetExistsError(Exception):
25
+ """Целевой файл уже существует — повторный вызов не перезаписывает данные,
26
+ добавленные в централизованное хранилище другим проектом после первой
27
+ миграции."""
28
+
29
+
30
+ def _dump_lines(db_path: Path) -> list[str]:
31
+ conn = sqlite3.connect(str(db_path))
32
+ try:
33
+ return list(conn.iterdump())
34
+ finally:
35
+ conn.close()
36
+
37
+
38
+ def _dumps_match(source: Path, target: Path) -> bool:
39
+ return _dump_lines(source) == _dump_lines(target)
40
+
41
+
42
+ def migrate_to_central_store(source: str | Path, target: str | Path) -> Path:
43
+ """Copy -> построчная сверка дампа -> backup-переименование источника.
44
+
45
+ Источник не удаляется — переименовывается в `<имя>.pre-migration-<дата>`
46
+ только после совпадения сверки. Не запускается неявно (NFR-12 AC-1) — вызов
47
+ только явный, из отдельной CLI-команды/скрипта, не из `Registry.__init__`.
48
+ """
49
+ source = Path(source)
50
+ target = Path(target)
51
+
52
+ # BLOCK-01/MAJ-03 (security review SEC-003): права цели — NFR-15 (0700/0600)
53
+ # независимо от прав источника и от ambient umask, безусловно, не только при
54
+ # первом создании каталога — `mkdir(exist_ok=True)` не чинит уже существующий
55
+ # каталог со слабыми правами.
56
+ target.parent.mkdir(parents=True, exist_ok=True)
57
+ os.chmod(target.parent, 0o700)
58
+
59
+ # MIN-01: check-then-act между `target.exists()` и созданием файла закрыт —
60
+ # `O_EXCL` делает проверку-и-создание одной атомарной операцией ОС, не двумя
61
+ # раздельными шагами. Явный `mode=0o600` при создании — права заданы с
62
+ # рождения файла, не пост-хок `chmod`/`copy2` (который перенёс бы биты
63
+ # источника поверх).
64
+ try:
65
+ fd = os.open(str(target), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
66
+ except FileExistsError as exc:
67
+ raise MigrationTargetExistsError(
68
+ f"Целевой файл уже существует: {target} — повторная миграция не "
69
+ "перезаписывает данные, добавленные после предыдущей миграции"
70
+ ) from exc
71
+
72
+ try:
73
+ with os.fdopen(fd, "wb") as dst_f, open(source, "rb") as src_f:
74
+ shutil.copyfileobj(src_f, dst_f)
75
+ os.chmod(target, 0o600) # copyfileobj не переносит metadata — явный chmod всё равно
76
+ except BaseException:
77
+ target.unlink(missing_ok=True)
78
+ raise
79
+
80
+ if not _dumps_match(source, target):
81
+ target.unlink(missing_ok=True)
82
+ raise MigrationVerificationError(
83
+ f"Сверка дампа не совпала: {source} != {target}; источник не тронут, "
84
+ "целевой файл удалён"
85
+ )
86
+
87
+ backup = source.with_name(f"{source.name}.pre-migration-{date.today().isoformat()}")
88
+ source.rename(backup)
89
+ return target
@@ -0,0 +1,101 @@
1
+ """Файл session-токена — третий источник авторизации после `KTALK_PERSONAL_API_KEY`
2
+ и `KTALK_SESSION_TOKEN` (ADR-003 расширен: приоритет ключ -> env-сессия -> файл).
3
+
4
+ Зачем файл вообще нужен. Session-токен протухает и обновляется руками; окружение
5
+ для этого — плохой носитель: экспорт в `.zshrc` не доходит до MCP-сервера,
6
+ запущенного Claude Code, а `.env` привязан к рабочей директории. Файл лежит в
7
+ одном месте, переживает перезапуск обоих процессов и обновляется одной командой.
8
+
9
+ Каталог `ktalk-mcp/`, не `ktalk/`: в `ktalk/` живёт санкция на запись (ADR-016),
10
+ у неё другой жизненный цикл и другой владелец решения.
11
+
12
+ Права шире `0600` читаются как «файла нет» — тот же fail-closed барьер, что у
13
+ санкции записи (SEC-006). Это защита не от владельца учётной записи, а от другого
14
+ пользователя машины, которому файл стал доступен.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+ import stat
22
+ from pathlib import Path
23
+
24
+ # Наблюдаемый формат session-токена — одно слово из букв и цифр (боевой токен
25
+ # 2026-08-21: 20 символов; прецедент mainpart проверяет `^[A-Za-z0-9]{16,40}$`,
26
+ # см. research/mainpart-ktalk-mcp.md §«Авторизация», п.2). Верхняя граница поднята
27
+ # до 64 с запасом: узкая граница отвергла бы валидный токен, широкая — пропускает
28
+ # ровно то, что и должна отвергать (пути, JSON, куски команд из устаревшего буфера).
29
+ _TOKEN_RE = re.compile(r"^[A-Za-z0-9]{16,64}$")
30
+
31
+
32
+ def token_path() -> Path:
33
+ """`KTALK_TOKEN_FILE` (полный путь) > `$XDG_CONFIG_HOME/ktalk-mcp/token` >
34
+ `~/.config/ktalk-mcp/token`."""
35
+ override = os.environ.get("KTALK_TOKEN_FILE")
36
+ if override:
37
+ return Path(override)
38
+ xdg_config_home = os.environ.get("XDG_CONFIG_HOME") or None
39
+ root = Path(xdg_config_home) if xdg_config_home else Path.home() / ".config"
40
+ return root / "ktalk-mcp" / "token"
41
+
42
+
43
+ def file_mode() -> str | None:
44
+ """Права файла как `0600` — для диагностики. `None`, если файла нет."""
45
+ try:
46
+ return format(stat.S_IMODE(token_path().stat().st_mode), "04o")
47
+ except OSError:
48
+ return None
49
+
50
+
51
+ def read_token() -> str | None:
52
+ """Значение токена или `None` — если файла нет, он нечитаем, пуст или его
53
+ права шире `0600`. Ни одна из этих причин не является ошибкой: вызывающая
54
+ сторона просто переходит к следующему источнику (или к отказу конфигурации)."""
55
+ path = token_path()
56
+ try:
57
+ if stat.S_IMODE(path.stat().st_mode) & 0o077:
58
+ return None
59
+ raw = path.read_text(encoding="utf-8")
60
+ except (OSError, ValueError, UnicodeDecodeError):
61
+ return None
62
+ token = raw.strip()
63
+ return token or None
64
+
65
+
66
+ def write_token(value: str) -> Path:
67
+ """Пишет токен с правами `0600` в каталоге `0700` и возвращает путь.
68
+
69
+ `os.replace` поверх временного файла: параллельно читающий процесс видит либо
70
+ старый токен целиком, либо новый целиком, но никогда полупустой файл. Права
71
+ выставляются до переименования — окна, в котором секрет лежит доступным всем,
72
+ не существует.
73
+ """
74
+ token = value.strip()
75
+ if not token:
76
+ raise ValueError("Пустой токен: записывать нечего.")
77
+ if not _TOKEN_RE.match(token):
78
+ # Значение в текст ошибки не попадает: буфер обмена мог содержать чужой
79
+ # секрет. Названы только длина и правило — этого хватает, чтобы понять,
80
+ # что скопировано не то (находка живой проверки 2026-08-21).
81
+ raise ValueError(
82
+ f"Значение длиной {len(token)} символ(ов) не похоже на токен: "
83
+ "ожидается одно слово из букв и цифр, 16-64 символа. "
84
+ "Проверьте, что в буфере результат `copy(JSON.parse(localStorage.session).data.token)`."
85
+ )
86
+
87
+ path = token_path()
88
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
89
+ path.parent.chmod(0o700)
90
+
91
+ tmp = path.with_name(path.name + ".tmp")
92
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
93
+ try:
94
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
95
+ handle.write(token)
96
+ except BaseException:
97
+ tmp.unlink(missing_ok=True)
98
+ raise
99
+ os.replace(tmp, path)
100
+ path.chmod(0o600)
101
+ return path
@@ -0,0 +1,94 @@
1
+ """ADR-016 §4/§5: журнал пишущих операций — прослеживаемость каждой попытки.
2
+
3
+ Две строки на операцию, не одна: `attempt` без парной `outcome` — машинный след
4
+ «процесс не дожил до ответа», отличимый от «ответа не было» (`result: unknown`).
5
+ Именно этот класс исходов стоил волне 0.6.0 четырёх разборов вручную.
6
+
7
+ Отказ записи `attempt` — fail-closed: сетевого вызова не будет. Иначе
8
+ прослеживаемость исчезает ровно тогда, когда она нужна.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ from ktalk_cli.config import redact_secrets
19
+
20
+
21
+ class JournalUnavailableError(OSError):
22
+ """Журнал недоступен для записи — пишущая операция не выполняется."""
23
+
24
+
25
+ def journal_path() -> Path:
26
+ xdg_state_home = os.environ.get("XDG_STATE_HOME") or None
27
+ root = Path(xdg_state_home) if xdg_state_home else Path.home() / ".local" / "state"
28
+ return root / "ktalk" / "write-ops.jsonl"
29
+
30
+
31
+ def _append(record: dict) -> None:
32
+ path = journal_path()
33
+ try:
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ os.chmod(path.parent, 0o700)
36
+ existed = path.exists()
37
+ with path.open("a", encoding="utf-8") as handle:
38
+ handle.write(json.dumps(record, ensure_ascii=False) + "\n")
39
+ if not existed:
40
+ os.chmod(path, 0o600)
41
+ except OSError as exc:
42
+ raise JournalUnavailableError(f"Журнал операций недоступен для записи: {exc}") from exc
43
+
44
+
45
+ def _now() -> str:
46
+ return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
47
+
48
+
49
+ def append_attempt(
50
+ *,
51
+ operation: str,
52
+ channel: str,
53
+ confirmation_id: str,
54
+ body_sha256: str,
55
+ body: dict,
56
+ sanction_remaining: int | None,
57
+ ) -> None:
58
+ """Тело проходит `redact_secrets` тем же вызовом, что и вывод ошибок — новой
59
+ точки маскирования не вводится (NFR-21)."""
60
+ _append(
61
+ {
62
+ "ts": _now(),
63
+ "event": "attempt",
64
+ "operation": operation,
65
+ "channel": channel,
66
+ "confirmation_id": confirmation_id,
67
+ "body_sha256": body_sha256,
68
+ "body": json.loads(redact_secrets(json.dumps(body, ensure_ascii=False))),
69
+ "sanction_remaining": sanction_remaining,
70
+ }
71
+ )
72
+
73
+
74
+ def append_outcome(
75
+ *,
76
+ operation: str,
77
+ channel: str,
78
+ confirmation_id: str,
79
+ body_sha256: str,
80
+ result: str,
81
+ status_code: int | None,
82
+ ) -> None:
83
+ _append(
84
+ {
85
+ "ts": _now(),
86
+ "event": "outcome",
87
+ "operation": operation,
88
+ "channel": channel,
89
+ "confirmation_id": confirmation_id,
90
+ "body_sha256": body_sha256,
91
+ "result": result,
92
+ "status_code": status_code,
93
+ }
94
+ )
@@ -0,0 +1,187 @@
1
+ """ADR-016 §2: файловая санкция контура записи — срок, бюджет, независимые ключи.
2
+
3
+ Механика ADR-014 §2 (fail-closed чтение, атомарная запись, права 0700/0600)
4
+ переиспользована целиком; отличия продиктованы предметом — запись в боевой контур,
5
+ а не установка пакета: срок и бюджет обязательны и конечны, ключ на каждую операцию.
6
+
7
+ Проверки интерактивного терминала здесь нет намеренно: она живёт в `cli_sanction.py`.
8
+ Модуль остаётся тестируемым, барьер — непроходимым из кода, потому что единственный
9
+ путь к `grant()` снаружи пакета проходит через CLI.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import tomllib
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timedelta, timezone
18
+ from pathlib import Path
19
+
20
+ OPERATIONS = ("create_meeting", "cancel_meeting")
21
+
22
+ # Дизайн-выбор, не измеренная величина (тот же класс решения, что CONFIRMATION_TTL):
23
+ # обоснование и правило пересмотра — ADR-016-agent-executed-writes-spec.md §2.
24
+ DEFAULT_HOURS = 8
25
+ MAX_HOURS = 24 * 7
26
+ DEFAULT_OPERATIONS = 3
27
+ MAX_OPERATIONS = 20
28
+
29
+
30
+ class SanctionError(Exception):
31
+ """Санкция не действует — код возврата несёт вызывающий CLI (ADR-016-spec §3)."""
32
+
33
+ def __init__(self, status: str) -> None:
34
+ super().__init__(f"Санкция на запись не действует: {status}")
35
+ self.status = status
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SanctionState:
40
+ """`status`: active | absent | expired | exhausted. `absent` — общий исход для
41
+ отсутствия, отзыва и любой порчи файла (fail-closed, ADR-016 §4)."""
42
+
43
+ operation: str
44
+ status: str
45
+ expires_at: str | None = None
46
+ remaining: int | None = None
47
+
48
+ @property
49
+ def active(self) -> bool:
50
+ return self.status == "active"
51
+
52
+
53
+ def sanction_path() -> Path:
54
+ xdg_config_home = os.environ.get("XDG_CONFIG_HOME") or None
55
+ root = Path(xdg_config_home) if xdg_config_home else Path.home() / ".config"
56
+ return root / "ktalk" / "write-sanction.toml"
57
+
58
+
59
+ def _read_raw() -> dict:
60
+ path = sanction_path()
61
+ try:
62
+ mode = path.stat().st_mode
63
+ # SEC-006: права шире 0600 читаются как «санкции нет». Это не защита от
64
+ # владельца учётной записи (он и так может переписать файл), а защита от
65
+ # другого пользователя машины, которому файл стал доступен на запись.
66
+ if mode & 0o077:
67
+ return {}
68
+ with path.open("rb") as handle:
69
+ data = tomllib.load(handle)
70
+ except (OSError, tomllib.TOMLDecodeError, ValueError):
71
+ return {}
72
+ return data if isinstance(data, dict) else {}
73
+
74
+
75
+ def _parse_utc(raw: object) -> datetime | None:
76
+ if not isinstance(raw, str):
77
+ return None
78
+ try:
79
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
80
+ except ValueError:
81
+ return None
82
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
83
+
84
+
85
+ def read_state(operation: str, *, now: datetime | None = None) -> SanctionState:
86
+ """Все условия обязаны выполниться одновременно; любое иное состояние — `absent`.
87
+ Различаются только `expired` и `exhausted`: они полезны оператору на его
88
+ собственной машине и не являются секретом (ADR-016-spec §2)."""
89
+ _check_operation(operation)
90
+ section = _read_raw().get(operation)
91
+ if not isinstance(section, dict) or section.get("allowed") is not True:
92
+ return SanctionState(operation, "absent")
93
+
94
+ expires_at = _parse_utc(section.get("expires_at"))
95
+ remaining = section.get("remaining")
96
+ if expires_at is None or not isinstance(remaining, int) or isinstance(remaining, bool):
97
+ return SanctionState(operation, "absent")
98
+
99
+ iso = expires_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
100
+ if expires_at <= (now or datetime.now(timezone.utc)):
101
+ return SanctionState(operation, "expired", iso, remaining)
102
+ if remaining <= 0:
103
+ return SanctionState(operation, "exhausted", iso, remaining)
104
+ return SanctionState(operation, "active", iso, remaining)
105
+
106
+
107
+ def _check_operation(operation: str) -> None:
108
+ if operation not in OPERATIONS:
109
+ raise ValueError(f"Неизвестная операция: {operation!r}; допустимы {OPERATIONS}")
110
+
111
+
112
+ def _write_raw(data: dict) -> None:
113
+ path = sanction_path()
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ os.chmod(path.parent, 0o700)
116
+ lines: list[str] = []
117
+ for operation in OPERATIONS:
118
+ section = data.get(operation)
119
+ if not isinstance(section, dict):
120
+ continue
121
+ lines.append(f"[{operation}]")
122
+ for key, value in section.items():
123
+ if isinstance(value, bool):
124
+ lines.append(f"{key} = {str(value).lower()}")
125
+ elif isinstance(value, int):
126
+ lines.append(f"{key} = {value}")
127
+ else:
128
+ lines.append(f'{key} = "{value}"')
129
+ lines.append("")
130
+ tmp = path.with_name(f".{path.name}.tmp")
131
+ tmp.write_text("\n".join(lines), encoding="utf-8")
132
+ os.chmod(tmp, 0o600)
133
+ tmp.replace(path)
134
+
135
+
136
+ def grant(
137
+ operation: str,
138
+ *,
139
+ hours: int = DEFAULT_HOURS,
140
+ operations: int = DEFAULT_OPERATIONS,
141
+ now: datetime | None = None,
142
+ ) -> SanctionState:
143
+ """Запрос за потолком отклоняется, а не усекается молча: усечение выдало бы
144
+ мандат, о котором оператор не просил и о котором он не знает."""
145
+ _check_operation(operation)
146
+ if not 1 <= hours <= MAX_HOURS:
147
+ raise ValueError(f"Срок санкции — от 1 до {MAX_HOURS} часов, получено: {hours}")
148
+ if not 1 <= operations <= MAX_OPERATIONS:
149
+ raise ValueError(
150
+ f"Бюджет санкции — от 1 до {MAX_OPERATIONS} операций, получено: {operations}"
151
+ )
152
+
153
+ moment = now or datetime.now(timezone.utc)
154
+ data = _read_raw()
155
+ data[operation] = {
156
+ "allowed": True,
157
+ "expires_at": _iso(moment + timedelta(hours=hours)),
158
+ "remaining": operations,
159
+ "granted_at": _iso(moment),
160
+ }
161
+ _write_raw(data)
162
+ return read_state(operation)
163
+
164
+
165
+ def revoke(operation: str) -> None:
166
+ """Секция не удаляется — отзыв обязан быть наблюдаем в `status` (ADR-014 §2)."""
167
+ _check_operation(operation)
168
+ data = _read_raw()
169
+ section = data.get(operation)
170
+ data[operation] = {**(section if isinstance(section, dict) else {}), "allowed": False}
171
+ _write_raw(data)
172
+
173
+
174
+ def consume(operation: str) -> int:
175
+ """Списание — до сетевой попытки, независимо от исхода (NFR-22)."""
176
+ state = read_state(operation)
177
+ if not state.active:
178
+ raise SanctionError(state.status)
179
+ data = _read_raw()
180
+ remaining = int(state.remaining or 0) - 1
181
+ data[operation] = {**data[operation], "remaining": remaining}
182
+ _write_raw(data)
183
+ return remaining
184
+
185
+
186
+ def _iso(moment: datetime) -> str:
187
+ return moment.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")