agentflow-proof 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.
@@ -0,0 +1,402 @@
1
+ """Compile a task brief plus a workflow pack into an unlocked draft plan (#71).
2
+
3
+ This is the bridge from adaptive workflow *policy* (a #70 recommendation over a
4
+ #61 task brief, and a #17 workflow pack) to an executable Agentflow *plan*. It is
5
+ deterministic, provider-agnostic, and read-only at the module level: it never
6
+ locks a plan and never writes to disk (the CLI layer owns I/O).
7
+
8
+ The design treats the recommendation as a **lower-bound posture** and the pack as
9
+ the **executable catalog**. ``recommend`` says how careful the workflow must be;
10
+ the pack profile is the real, representable workflow whose plan template provides
11
+ the step topology. ``draft-plan`` selects the least-strict pack profile that
12
+ satisfies the recommended posture, hydrates that profile's template with the
13
+ brief, and emits an unlocked ``plan.lock.json`` draft that passes
14
+ ``validate_plan``. ``lock-plan`` remains the only authority for validation and
15
+ locking.
16
+ """
17
+
18
+ from copy import deepcopy
19
+ from pathlib import Path
20
+ from typing import Any, Dict, Optional, Tuple
21
+
22
+ from . import packs
23
+ from .contracts import REVIEW_DEPTH_ORDER, REVIEW_DEPTH_POLICY
24
+ from .packs import _safe_relative_path
25
+ from .recommend import ARCHETYPES_BY_ID, LARGE_FILE_MIN, recommend, validate_brief
26
+ from .validation import validate_plan
27
+ from .workflow_contract import WORKFLOW_CONTRACT_PATH
28
+
29
+ DRAFT_PLAN_SCHEMA_VERSION = "0.2.0" # 0.2.0: output payload always carries a `warnings` array (#89)
30
+ SELECTION_MODE = "least_strict_satisfying_profile"
31
+ WORKFLOW_BLOCK_KEY = "workflow"
32
+
33
+ # #90: a profile that requires a review run will, at proof time, write review
34
+ # artifacts under this path; scope it into allowed_files so audit-drift does not
35
+ # flag them.
36
+ # ponytail: keep this constant until state_root actually varies per pack.
37
+ REVIEW_STATE_PATH = "docs/ai/state/"
38
+
39
+ # Strictness ladders. A profile satisfies a lower bound only when it is equal or
40
+ # stricter on every axis; "least strict" minimizes this tuple lexicographically.
41
+ # No recommend archetype emits `spec_quality`; it is ranked between `standard`
42
+ # and `deep` so a pack-only profile that uses it sorts sensibly during selection.
43
+ # Review depth ranking lives in contracts so plan selection and proof checks stay aligned.
44
+ HUNK_ORDER = {"off": 0, "observe": 1, "enforce": 2}
45
+ RISK_ORDER = {"low": 0, "medium": 1, "high": 2}
46
+
47
+ # Task types whose candidate files are edits to existing code and must pre-exist.
48
+ FILE_BACKED_TASK_TYPES = ("bugfix", "refactor", "docs")
49
+
50
+
51
+ class DraftPlanError(ValueError):
52
+ """A fail-closed compile diagnostic carrying a machine-readable ``code``."""
53
+
54
+ def __init__(self, message: str, code: str = "draft_plan_error") -> None:
55
+ super().__init__(message)
56
+ self.code = code
57
+
58
+
59
+ def _required_cap_ids(profile_like: Dict[str, Any]) -> set:
60
+ return {
61
+ cap["id"]
62
+ for cap in profile_like.get("required_capabilities", [])
63
+ if cap.get("required")
64
+ }
65
+
66
+
67
+ def _profile_requires_review_run(profile: Dict[str, Any]) -> bool:
68
+ """Return True when the profile mandates a recorded review run at proof time.
69
+
70
+ Mirrors the proof-time condition exactly: either the profile sets
71
+ ``proof_policy.require_review_run`` or its ``review_depth`` carries a
72
+ requires-run obligation in :data:`REVIEW_DEPTH_POLICY` (``spec_quality`` /
73
+ ``deep``). Keeping this in lockstep with the policy means a deep profile that
74
+ leaves the explicit flag False still gets its review-state path scoped in.
75
+ """
76
+ if profile["proof_policy"].get("require_review_run"):
77
+ return True
78
+ _, requires_run = REVIEW_DEPTH_POLICY.get(profile["review_depth"], ("warn", False))
79
+ return requires_run
80
+
81
+
82
+ def lower_bound_from_recommendation(report: Dict[str, Any]) -> Dict[str, Any]:
83
+ """Extract the minimum-safety *posture* from a ``recommend`` report.
84
+
85
+ The floor is the recommended archetype's intrinsic posture, NOT the report's
86
+ ``workflow_contract_candidate`` (which also folds in the brief's ad-hoc
87
+ ``validation_needs``). Those needs are operator gate requests added to the
88
+ plan at hydration; they must not force every pack profile to pre-declare them.
89
+ """
90
+ archetype = ARCHETYPES_BY_ID[report["recommended"]["profile"]]
91
+ return {
92
+ "required_gates": set(archetype.required_gates),
93
+ "required_capabilities": {
94
+ cap["id"] for cap in archetype.required_capabilities if cap.get("required")
95
+ },
96
+ "review_depth": archetype.review_depth,
97
+ "hunk_attribution": archetype.hunk_attribution,
98
+ "require_review_run": archetype.require_review_run,
99
+ }
100
+
101
+
102
+ def profile_satisfies(
103
+ profile: Dict[str, Any], manifest: Dict[str, Any], lower_bound: Dict[str, Any]
104
+ ) -> bool:
105
+ """Return True when a pack profile meets or exceeds the lower-bound posture."""
106
+ gates = set(profile["validation_policy"]["required_gates"])
107
+ if not lower_bound["required_gates"] <= gates:
108
+ return False
109
+ if not lower_bound["required_capabilities"] <= _required_cap_ids(profile):
110
+ return False
111
+ if REVIEW_DEPTH_ORDER[profile["review_depth"]] < REVIEW_DEPTH_ORDER[lower_bound["review_depth"]]:
112
+ return False
113
+ if (
114
+ HUNK_ORDER[profile["proof_policy"]["hunk_attribution"]]
115
+ < HUNK_ORDER[lower_bound["hunk_attribution"]]
116
+ ):
117
+ return False
118
+ if lower_bound["require_review_run"] and not _profile_requires_review_run(profile):
119
+ return False
120
+ # The profile's plan template must actually carry the required gates, else the
121
+ # compiled plan could not run them.
122
+ template = manifest.get("plan_templates", {}).get(profile["plan_template"], {})
123
+ if not lower_bound["required_gates"] <= set(template.get("validation_gates", [])):
124
+ return False
125
+ return True
126
+
127
+
128
+ def profile_strictness(profile: Dict[str, Any]) -> Tuple[int, int, int, int, int]:
129
+ """A lexicographic strictness key; lower means less strict."""
130
+ return (
131
+ REVIEW_DEPTH_ORDER[profile["review_depth"]],
132
+ HUNK_ORDER[profile["proof_policy"]["hunk_attribution"]],
133
+ int(profile["proof_policy"]["require_review_run"]),
134
+ len(profile["validation_policy"]["required_gates"]),
135
+ len(_required_cap_ids(profile)),
136
+ )
137
+
138
+
139
+ def select_profile(
140
+ manifest: Dict[str, Any],
141
+ lower_bound: Dict[str, Any],
142
+ *,
143
+ profile_id: Optional[str] = None,
144
+ reason: Optional[str] = None,
145
+ ) -> Tuple[Dict[str, Any], str]:
146
+ """Select the pack profile that becomes the plan's final posture.
147
+
148
+ With ``profile_id`` the operator chooses explicitly; a profile weaker than the
149
+ recommendation is refused unless a ``reason`` is supplied. Without it, the
150
+ least-strict profile that satisfies the lower bound wins; zero matches or a tie
151
+ at the minimum fails closed.
152
+ """
153
+ if profile_id is not None:
154
+ try:
155
+ profile = packs.find_profile(manifest, profile_id)
156
+ except packs.PackError as exc:
157
+ raise DraftPlanError(str(exc), code="unknown_profile") from exc
158
+ if profile_satisfies(profile, manifest, lower_bound):
159
+ return profile, "operator_selected"
160
+ if not (reason and reason.strip()):
161
+ raise DraftPlanError(
162
+ f"profile {profile_id!r} is weaker than the recommended posture; "
163
+ "pass --reason to override",
164
+ code="profile_weaker_than_recommended",
165
+ )
166
+ return profile, "operator_override"
167
+
168
+ candidates = [
169
+ p for p in manifest["profiles"] if profile_satisfies(p, manifest, lower_bound)
170
+ ]
171
+ if not candidates:
172
+ available = ", ".join(p["id"] for p in manifest["profiles"])
173
+ raise DraftPlanError(
174
+ "no workflow pack profile satisfies the recommended posture "
175
+ f"(available: {available})",
176
+ code="no_satisfying_profile",
177
+ )
178
+ best_key = min(profile_strictness(p) for p in candidates)
179
+ minimal = [p for p in candidates if profile_strictness(p) == best_key]
180
+ if len(minimal) > 1:
181
+ tied = ", ".join(p["id"] for p in minimal)
182
+ raise DraftPlanError(
183
+ f"multiple pack profiles are equally least-strict ({tied}); "
184
+ "pass --profile to choose one",
185
+ code="ambiguous_profile",
186
+ )
187
+ return minimal[0], SELECTION_MODE
188
+
189
+
190
+ def is_broad(report: Dict[str, Any]) -> bool:
191
+ """True when the recommendation reflects broad / cross-cutting work."""
192
+ profile = report["recommended"]["profile"]
193
+ broad_signal = "broad_signal=true" in report["signals"]
194
+ return profile == "large-feature" or (profile == "high-risk" and broad_signal)
195
+
196
+
197
+ def _max_risk(*levels: str) -> str:
198
+ return max(levels, key=lambda level: RISK_ORDER.get(level, 0))
199
+
200
+
201
+ def _missing_candidate_files(brief: Dict[str, Any], root: Path) -> list:
202
+ """Return candidate files that must pre-exist but do not (else an empty list).
203
+
204
+ Only file-backed task types (edits to existing code) require pre-existence;
205
+ greenfield task types name files they are about to create.
206
+ """
207
+ if brief["task_type"] not in FILE_BACKED_TASK_TYPES:
208
+ return []
209
+ return [f for f in (brief.get("candidate_files") or []) if not (root / f).exists()]
210
+
211
+
212
+ def _candidate_escapes_root(root: Path, relative: str) -> bool:
213
+ try:
214
+ resolved_root = root.resolve(strict=False)
215
+ (resolved_root / relative).resolve(strict=False).relative_to(resolved_root)
216
+ return False
217
+ except (OSError, RuntimeError, ValueError):
218
+ return True
219
+
220
+
221
+ def _check_adequacy(report: Dict[str, Any], brief: Dict[str, Any], template: Dict[str, Any]) -> None:
222
+ n_steps = len([s for s in template.get("steps", []) if isinstance(s, dict)])
223
+ if n_steps >= 2:
224
+ return
225
+ files = brief.get("candidate_files") or []
226
+ if brief.get("declared_size") == "xl" or len(files) >= LARGE_FILE_MIN:
227
+ raise DraftPlanError(
228
+ "brief is too large for a single-step template; provide a multi-step "
229
+ "bounded template or split the brief",
230
+ code="task_too_large",
231
+ )
232
+ if is_broad(report):
233
+ raise DraftPlanError(
234
+ "recommended posture is broad but the selected template has fewer than "
235
+ "two steps; decompose the brief or choose a multi-step template",
236
+ code="decomposition_required",
237
+ )
238
+
239
+
240
+ def _hydrate(
241
+ plan: Dict[str, Any],
242
+ brief: Dict[str, Any],
243
+ objective: str,
244
+ profile: Dict[str, Any],
245
+ manifest: Dict[str, Any],
246
+ report: Dict[str, Any],
247
+ selection_mode: str,
248
+ ) -> Dict[str, Any]:
249
+ plan = deepcopy(plan)
250
+ plan["objective"] = objective.strip()
251
+ needs = brief.get("validation_needs") or []
252
+ plan["validation_gates"] = sorted(
253
+ set(plan.get("validation_gates", []))
254
+ | set(profile["validation_policy"]["required_gates"])
255
+ | set(needs)
256
+ )
257
+ candidate_files = brief.get("candidate_files") or []
258
+ # Preserve order, dedupe, and guarantee .agent/ is writable for the loop.
259
+ # When the profile requires a review run (#90), scope the review-state path
260
+ # too so recorded review artifacts are not flagged as out-of-scope drift.
261
+ extra_scope = [".agent/"]
262
+ if _profile_requires_review_run(profile):
263
+ extra_scope.append(REVIEW_STATE_PATH)
264
+ plan["allowed_files"] = list(
265
+ dict.fromkeys([*plan.get("allowed_files", []), *candidate_files, *extra_scope])
266
+ )
267
+ plan["risk_level"] = _max_risk(plan.get("risk_level", "low"), brief["declared_risk"])
268
+ # Pointer/provenance only; the authoritative policy lives in the linked
269
+ # workflow contract, not duplicated into the plan body.
270
+ plan[WORKFLOW_BLOCK_KEY] = {
271
+ "contract_path": WORKFLOW_CONTRACT_PATH,
272
+ "workflow_pack": manifest["id"],
273
+ "workflow_profile": profile["id"],
274
+ "recommended_profile": report["recommended"]["profile"],
275
+ "selection_mode": selection_mode,
276
+ }
277
+ plan["locked"] = False
278
+ plan["locked_at"] = None
279
+ return plan
280
+
281
+
282
+ def selection_reason(
283
+ report: Dict[str, Any],
284
+ profile: Dict[str, Any],
285
+ selection_mode: str,
286
+ reason: Optional[str],
287
+ ) -> str:
288
+ """Human-readable rationale recorded in the linked workflow contract."""
289
+ recommended = report["recommended"]["profile"]
290
+ if selection_mode == "operator_override":
291
+ return (
292
+ f"Operator override to profile {profile['id']} (weaker than recommended "
293
+ f"{recommended}). {reason}"
294
+ )
295
+ if selection_mode == "operator_selected":
296
+ return (
297
+ f"Operator selected profile {profile['id']} (satisfies recommended {recommended})."
298
+ )
299
+ return (
300
+ f"Selected profile {profile['id']} as the least-strict pack profile satisfying "
301
+ f"recommended {recommended}."
302
+ )
303
+
304
+
305
+ def compile_draft_plan(
306
+ brief: Dict[str, Any],
307
+ manifest: Dict[str, Any],
308
+ *,
309
+ objective: str,
310
+ root: Path,
311
+ profile_id: Optional[str] = None,
312
+ reason: Optional[str] = None,
313
+ allow_missing_candidates: bool = False,
314
+ ) -> Dict[str, Any]:
315
+ """Compile a brief + pack into an unlocked draft plan.
316
+
317
+ ``manifest`` must be a ``load_pack``-validated pack manifest; the selection
318
+ and hydration logic trusts its schema (profiles, ``validation_policy``,
319
+ ``proof_policy``, enum-constrained ``review_depth``/``hunk_attribution``).
320
+ Returns ``{"plan", "profile", "report", "selection_mode", "template_id",
321
+ "warnings"}``. Raises :class:`DraftPlanError` (with a machine-readable
322
+ ``code``) on any fail-closed condition. The returned plan passes
323
+ ``validate_plan`` and is unlocked.
324
+
325
+ ``allow_missing_candidates`` is the #89 greenfield switch: a file-backed
326
+ brief whose ``candidate_files`` do not exist yet normally fails closed with
327
+ ``candidate_file_missing``; with this flag the absence is downgraded to a
328
+ returned warning so a plan can be drafted for files about to be created.
329
+ Path-escape rejection (``candidate_file_unsafe``) is never downgraded.
330
+ """
331
+ errors = validate_brief(brief)
332
+ if errors:
333
+ raise DraftPlanError("invalid brief: " + "; ".join(errors), code="invalid_brief")
334
+
335
+ # Candidate files are brief-controlled and flow into the existence probe and
336
+ # the draft's allowed_files; reject anything that could escape the repo root.
337
+ unsafe = [
338
+ f
339
+ for f in (brief.get("candidate_files") or [])
340
+ if not _safe_relative_path(f) or _candidate_escapes_root(root, f)
341
+ ]
342
+ if unsafe:
343
+ raise DraftPlanError(
344
+ "candidate files must be contained relative paths: " + ", ".join(unsafe),
345
+ code="candidate_file_unsafe",
346
+ )
347
+
348
+ if not (objective and objective.strip()):
349
+ raise DraftPlanError(
350
+ "an objective is required to compile a plan", code="brief_too_vague"
351
+ )
352
+
353
+ report = recommend(brief)
354
+ lower_bound = lower_bound_from_recommendation(report)
355
+
356
+ if is_broad(report) and not (brief.get("candidate_files") or []):
357
+ raise DraftPlanError(
358
+ "recommended posture is broad but the brief lists no candidate_files to "
359
+ "bound the change",
360
+ code="brief_too_vague",
361
+ )
362
+
363
+ warnings: list = []
364
+ missing = _missing_candidate_files(brief, root)
365
+ if missing:
366
+ message = "candidate files do not exist under the repo root: " + ", ".join(missing)
367
+ if allow_missing_candidates:
368
+ warnings.append({"code": "candidate_file_missing", "message": message})
369
+ else:
370
+ raise DraftPlanError(message, code="candidate_file_missing")
371
+
372
+ profile, selection_mode = select_profile(
373
+ manifest, lower_bound, profile_id=profile_id, reason=reason
374
+ )
375
+ template_id = profile["plan_template"]
376
+ if template_id not in manifest.get("plan_templates", {}):
377
+ raise DraftPlanError(
378
+ f"selected profile references unknown plan template: {template_id}",
379
+ code="workflow_inputs_unavailable",
380
+ )
381
+ template = manifest["plan_templates"][template_id]
382
+
383
+ _check_adequacy(report, brief, template)
384
+
385
+ plan = packs.template_to_plan(manifest, template_id)
386
+ plan = _hydrate(plan, brief, objective, profile, manifest, report, selection_mode)
387
+
388
+ plan_errors = validate_plan(plan)
389
+ if plan_errors:
390
+ raise DraftPlanError(
391
+ "compiled plan failed validation: " + "; ".join(plan_errors),
392
+ code="plan_invalid",
393
+ )
394
+
395
+ return {
396
+ "plan": plan,
397
+ "profile": profile,
398
+ "report": report,
399
+ "selection_mode": selection_mode,
400
+ "template_id": template_id,
401
+ "warnings": warnings,
402
+ }
agentflow/events.py ADDED
@@ -0,0 +1,201 @@
1
+ """Read-only event-stream projection over Agentflow's .agent/ ledgers.
2
+
3
+ The five append-only ledgers remain authoritative. This module derives a single
4
+ chronological view and never writes to disk.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import date, datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Dict, List
12
+
13
+ from .artifacts import read_jsonl
14
+ from .contracts import ARTIFACT_PATHS
15
+
16
+ # Ledger projection order. Rank only breaks ties at identical timestamps; it
17
+ # never reorders events that already differ in time.
18
+ _LEDGER_ORDER = (
19
+ "step-runs",
20
+ "command-receipts",
21
+ "file-receipts",
22
+ "verification-runs",
23
+ "review-runs",
24
+ )
25
+ _LEDGER_RANK = {name: index for index, name in enumerate(_LEDGER_ORDER)}
26
+
27
+
28
+ def _collect(record: Dict[str, Any], keys: tuple) -> Dict[str, Any]:
29
+ return {key: record[key] for key in keys if record.get(key) is not None}
30
+
31
+
32
+ def _step_event(record: Dict[str, Any], index: int) -> Dict[str, Any]:
33
+ kind = record.get("event")
34
+ return {
35
+ "type": f"step.{kind}" if kind else "step.unknown",
36
+ "timestamp": record.get("recorded_at", ""),
37
+ "step_id": record.get("step_id"),
38
+ "attempt_id": record.get("attempt_id"),
39
+ "source": {"ledger": "step-runs", "record_id": None, "index": index},
40
+ "data": _collect(
41
+ record,
42
+ (
43
+ "agent_id",
44
+ "lease_expires_at",
45
+ "reason",
46
+ "reason_code",
47
+ "amends_attempt",
48
+ "amends_completed_at",
49
+ "findings",
50
+ "finding_refs",
51
+ "abandoned_by",
52
+ "superseded_by",
53
+ "failed_by",
54
+ ),
55
+ ),
56
+ }
57
+
58
+
59
+ def _command_event(record: Dict[str, Any], index: int) -> Dict[str, Any]:
60
+ risk = record.get("risk") or {}
61
+ data = _collect(
62
+ record,
63
+ (
64
+ "command",
65
+ "exit_code",
66
+ "gate",
67
+ "cwd",
68
+ "provenance",
69
+ "decision",
70
+ "finished_at",
71
+ "timed_out",
72
+ "timeout_seconds",
73
+ "stdout_sha256",
74
+ "stderr_sha256",
75
+ ),
76
+ )
77
+ data["risk_level"] = risk.get("level")
78
+ data["finding_count"] = len(risk.get("findings", []))
79
+ return {
80
+ "type": "command.recorded",
81
+ "timestamp": record.get("started_at", ""),
82
+ "step_id": record.get("step_id"),
83
+ "attempt_id": record.get("attempt_id"),
84
+ "source": {"ledger": "command-receipts", "record_id": record.get("id"), "index": index},
85
+ "data": data,
86
+ }
87
+
88
+
89
+ def _file_event(record: Dict[str, Any], index: int) -> Dict[str, Any]:
90
+ return {
91
+ "type": "file.changed",
92
+ "timestamp": record.get("recorded_at", ""),
93
+ "step_id": record.get("step_id"),
94
+ "attempt_id": record.get("attempt_id"),
95
+ "source": {"ledger": "file-receipts", "record_id": record.get("id"), "index": index},
96
+ "data": _collect(record, ("path", "previous_path", "change_kind", "after_sha256")),
97
+ }
98
+
99
+
100
+ def _verification_event(record: Dict[str, Any], index: int) -> Dict[str, Any]:
101
+ data = _collect(record, ("scope", "status", "strict", "replay"))
102
+ findings = record.get("findings")
103
+ if isinstance(findings, list):
104
+ data["finding_count"] = len(findings)
105
+ return {
106
+ "type": "verification.run",
107
+ "timestamp": record.get("recorded_at", ""),
108
+ "step_id": record.get("step_id"),
109
+ "attempt_id": record.get("attempt_id"),
110
+ "source": {"ledger": "verification-runs", "record_id": record.get("id"), "index": index},
111
+ "data": data,
112
+ }
113
+
114
+
115
+ def _review_event(record: Dict[str, Any], index: int) -> Dict[str, Any]:
116
+ data = _collect(record, ("review_run_id", "gate_status", "policy"))
117
+ data["active_blocking_count"] = len(record.get("active_blocking", []) or [])
118
+ return {
119
+ "type": "review.recorded",
120
+ "timestamp": record.get("recorded_at", ""),
121
+ "step_id": None,
122
+ "attempt_id": None,
123
+ "source": {
124
+ "ledger": "review-runs",
125
+ "record_id": record.get("review_run_id"),
126
+ "index": index,
127
+ },
128
+ "data": data,
129
+ }
130
+
131
+
132
+ _PROJECTORS: Dict[str, Callable[[Dict[str, Any], int], Dict[str, Any]]] = {
133
+ "step-runs": _step_event,
134
+ "command-receipts": _command_event,
135
+ "file-receipts": _file_event,
136
+ "verification-runs": _verification_event,
137
+ "review-runs": _review_event,
138
+ }
139
+
140
+
141
+ def project_events(root: Path) -> List[Dict[str, Any]]:
142
+ """Project the five ledgers into one deterministically ordered event list."""
143
+ events: List[Dict[str, Any]] = []
144
+ for ledger in _LEDGER_ORDER:
145
+ path = root / ARTIFACT_PATHS[ledger]
146
+ projector = _PROJECTORS[ledger]
147
+ for index, record in enumerate(read_jsonl(path)):
148
+ if isinstance(record, dict):
149
+ events.append(projector(record, index))
150
+ events.sort(
151
+ key=lambda event: (
152
+ event["timestamp"],
153
+ _LEDGER_RANK[event["source"]["ledger"]],
154
+ event["source"]["index"],
155
+ )
156
+ )
157
+ return events
158
+
159
+
160
+ def _normalize_utc_suffix(value: str) -> str:
161
+ if value.endswith("Z"):
162
+ return f"{value[:-1]}+00:00"
163
+ return value
164
+
165
+
166
+ def _parse_iso_timestamp(value: str) -> datetime:
167
+ normalized = _normalize_utc_suffix(value)
168
+ try:
169
+ parsed = datetime.fromisoformat(normalized)
170
+ except ValueError:
171
+ parsed_date = date.fromisoformat(normalized)
172
+ parsed = datetime.combine(parsed_date, datetime.min.time())
173
+ if parsed.tzinfo is None:
174
+ parsed = parsed.replace(tzinfo=timezone.utc)
175
+ return parsed.astimezone(timezone.utc)
176
+
177
+
178
+ def filter_events_since(events: List[Dict[str, Any]], since: str) -> List[Dict[str, Any]]:
179
+ """Return events whose timestamp is at or after ``since`` (inclusive)."""
180
+ threshold = _parse_iso_timestamp(since)
181
+ filtered = []
182
+ for event in events:
183
+ timestamp = event.get("timestamp")
184
+ if not isinstance(timestamp, str):
185
+ continue
186
+ try:
187
+ event_time = _parse_iso_timestamp(timestamp)
188
+ except ValueError:
189
+ continue
190
+ if event_time >= threshold:
191
+ filtered.append(event)
192
+ return filtered
193
+
194
+
195
+ def valid_since(value: str) -> bool:
196
+ """True when ``value`` parses as an ISO-8601 date or datetime."""
197
+ try:
198
+ _parse_iso_timestamp(value)
199
+ except ValueError:
200
+ return False
201
+ return True