Coverage for node / src / stigmem_node / plugins / registry.py: 90%

400 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-25 01:49 +0000

1"""Hook registry dispatch machinery for PR 4-INF.1.""" 

2 

3from __future__ import annotations 

4 

5import contextlib 

6import inspect 

7import logging 

8import threading 

9import time 

10from collections.abc import Callable 

11from concurrent.futures import ThreadPoolExecutor 

12from concurrent.futures import TimeoutError as FutureTimeoutError 

13from dataclasses import dataclass 

14from datetime import UTC, datetime 

15from importlib.metadata import PackageNotFoundError, version 

16from typing import Any, TypeVar, cast 

17 

18from packaging.specifiers import InvalidSpecifier, SpecifierSet 

19from packaging.version import Version 

20 

21from stigmem_node.observability.metrics import ( 

22 PLUGIN_HANDLER_DURATION, 

23 PLUGIN_HANDLER_ERROR, 

24 PLUGIN_HANDLER_INVOCATION, 

25 PLUGIN_HANDLERS_PER_HOOK, 

26 PLUGIN_HOOK_DURATION, 

27 PLUGIN_HOOK_FIRE, 

28 PLUGIN_REGISTERED_COUNT, 

29 PLUGIN_REGISTRATION, 

30 PLUGIN_VOTING_DECISION, 

31) 

32 

33from .context import CoreApis, PluginContext 

34from .errors import ManifestError, PluginExecutionError, RegistryFrozenError, RejectError 

35from .handlers import ( 

36 ALLOW_SINGLETON, 

37 Allow, 

38 AuditEvent, 

39 Deny, 

40 Failure, 

41 PluginHealth, 

42 PluginHealthReport, 

43 PluginHealthStatus, 

44 PluginInfo, 

45 Success, 

46 VotingDecision, 

47) 

48from .hooks import HOOK_SPECS, HookOrdering, HookSemantic 

49from .manifest import PluginManifest 

50 

51logger = logging.getLogger("stigmem.plugins.registry") 

52_FALLBACK_STIGMEM_VERSION = "0.9.0a9" 

53 

54T = TypeVar("T") 

55 

56 

57@dataclass(frozen=True, slots=True) 

58class HandlerEntry: 

59 hook: str 

60 handler: Callable[..., Any] 

61 plugin_name: str 

62 handler_name: str 

63 ctx: PluginContext 

64 is_core: bool 

65 async_safe: bool 

66 timeout_seconds: float | None 

67 

68 

69class HookRegistry: 

70 """Deterministic in-process hook registry.""" 

71 

72 def __init__( 

73 self, 

74 *, 

75 core_apis: CoreApis | None = None, 

76 emit_metrics: bool = False, 

77 handler_timeout_seconds: float | None = None, 

78 ) -> None: 

79 if handler_timeout_seconds is not None: 

80 _validate_timeout_seconds(handler_timeout_seconds) 

81 self._handlers: dict[str, tuple[HandlerEntry, ...]] = {hook: () for hook in HOOK_SPECS} 

82 self._plugin_names: set[str] = set() 

83 self._plugin_order: list[str] = [] 

84 self._plugin_versions: dict[str, str] = {} 

85 self._plugin_infos: dict[str, PluginInfo] = {} 

86 self._plugin_signing_metadata: dict[str, dict[str, Any]] = {} 

87 self._plugin_contexts: dict[str, PluginContext] = {} 

88 self._plugin_health_checks: dict[str, Callable[..., Any]] = {} 

89 self._plugin_health_reports: dict[str, PluginHealthReport] = {} 

90 self._core_apis = core_apis or CoreApis() 

91 self._emit_metrics = emit_metrics 

92 self._handler_timeout_seconds = handler_timeout_seconds 

93 self._timeout_executor: ThreadPoolExecutor | None = None 

94 self._frozen = False 

95 self._emitting_registry_audit = False 

96 self._lock = threading.RLock() 

97 

98 def register_plugin( 

99 self, 

100 manifest: PluginManifest, 

101 *, 

102 discovery_source: dict[str, Any] | None = None, 

103 signing_identity: str = "unsigned", 

104 signing_metadata: dict[str, Any] | None = None, 

105 ) -> None: 

106 """Register all handlers from a manually supplied plugin manifest.""" 

107 audit_metadata = _registration_audit_metadata( 

108 discovery_source=discovery_source, 

109 signing_identity=signing_identity, 

110 signing_metadata=signing_metadata, 

111 ) 

112 with self._lock: 

113 self._ensure_mutable() 

114 if manifest.name in self._plugin_names: 

115 self._metric_inc(PLUGIN_REGISTRATION, outcome="failure", reason="duplicate") 

116 self._emit_registry_audit( 

117 "plugin.registration_failed", 

118 manifest=manifest, 

119 reason="duplicate", 

120 metadata=audit_metadata, 

121 ) 

122 raise ManifestError(f"plugin {manifest.name!r} is already registered") 

123 try: 

124 self._validate_manifest_compatibility(manifest) 

125 self._validate_manifest_handler_signatures(manifest) 

126 except ManifestError as exc: 

127 self._metric_inc(PLUGIN_REGISTRATION, outcome="failure", reason="manifest_invalid") 

128 self._emit_registry_audit( 

129 "plugin.registration_failed", 

130 manifest=manifest, 

131 reason="manifest_invalid", 

132 validation_failure=str(exc), 

133 metadata=audit_metadata, 

134 ) 

135 raise 

136 ctx = PluginContext( 

137 plugin_name=manifest.name, 

138 plugin_version=manifest.version, 

139 capabilities=manifest.capabilities, 

140 core_apis=self._core_apis, 

141 ) 

142 decision = self.fire_voting("config_validate", plugin=manifest) 

143 if isinstance(decision, Deny): 

144 self._metric_inc(PLUGIN_REGISTRATION, outcome="failure", reason="config_validate") 

145 self._emit_registry_audit( 

146 "plugin.registration_failed", 

147 manifest=manifest, 

148 reason="config_validate", 

149 validation_failure=decision.reason, 

150 metadata=audit_metadata, 

151 ) 

152 raise ManifestError( 

153 f"plugin {manifest.name!r} failed config validation: {decision.reason}" 

154 ) 

155 own_config_validator = manifest.hooks.get("config_validate") 

156 if own_config_validator is not None: 

157 try: 

158 own_decision = own_config_validator(ctx, plugin=manifest) 

159 except Exception as exc: 

160 self._metric_inc( 

161 PLUGIN_REGISTRATION, outcome="failure", reason="config_exception" 

162 ) 

163 self._emit_registry_audit( 

164 "plugin.registration_failed", 

165 manifest=manifest, 

166 reason="config_exception", 

167 validation_failure=str(exc), 

168 metadata=audit_metadata, 

169 ) 

170 raise ManifestError( 

171 f"plugin {manifest.name!r} config validator failed: {exc}" 

172 ) from exc 

173 if isinstance(own_decision, Deny): 

174 self._metric_inc( 

175 PLUGIN_REGISTRATION, outcome="failure", reason="config_validate" 

176 ) 

177 self._emit_registry_audit( 

178 "plugin.registration_failed", 

179 manifest=manifest, 

180 reason="config_validate", 

181 validation_failure=own_decision.reason, 

182 metadata=audit_metadata, 

183 ) 

184 raise ManifestError( 

185 f"plugin {manifest.name!r} failed config validation: {own_decision.reason}" 

186 ) 

187 if not isinstance(own_decision, Allow): 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true

188 self._metric_inc(PLUGIN_REGISTRATION, outcome="failure", reason="config_result") 

189 self._emit_registry_audit( 

190 "plugin.registration_failed", 

191 manifest=manifest, 

192 reason="config_result", 

193 validation_failure=type(own_decision).__name__, 

194 metadata=audit_metadata, 

195 ) 

196 raise ManifestError( 

197 f"plugin {manifest.name!r} config validator returned " 

198 f"{type(own_decision).__name__}; expected Allow or Deny" 

199 ) 

200 self._plugin_names.add(manifest.name) 

201 self._plugin_order.append(manifest.name) 

202 self._plugin_versions[manifest.name] = manifest.version 

203 self._plugin_infos[manifest.name] = PluginInfo( 

204 name=manifest.name, 

205 version=manifest.version, 

206 capabilities=tuple(sorted(manifest.capabilities)), 

207 hooks=tuple(sorted(manifest.hooks)), 

208 depends_on=tuple(sorted(manifest.depends_on)), 

209 discovery_source=audit_metadata["discovery_source"], 

210 signed_by=signing_identity, 

211 ) 

212 if signing_metadata is not None: 

213 self._plugin_signing_metadata[manifest.name] = dict(signing_metadata) 

214 self._plugin_contexts[manifest.name] = ctx 

215 if manifest.health_check is not None: 

216 self._plugin_health_checks[manifest.name] = manifest.health_check 

217 for hook, handler in manifest.hooks.items(): 

218 self._add_handler( 

219 hook=hook, 

220 handler=handler, 

221 plugin_name=manifest.name, 

222 handler_name=f"{manifest.name}.{getattr(handler, '__name__', 'handler')}", 

223 ctx=ctx, 

224 is_core=False, 

225 async_safe=manifest.async_safe, 

226 ) 

227 self._metric_inc(PLUGIN_REGISTRATION, outcome="success", reason="") 

228 self._metric_set(PLUGIN_REGISTERED_COUNT, len(self._plugin_names)) 

229 self._emit_registry_audit( 

230 "plugin.registered", 

231 manifest=manifest, 

232 metadata=audit_metadata, 

233 ) 

234 logger.info( 

235 "registered plugin %r version=%s hooks=%s capabilities=%s", 

236 manifest.name, 

237 manifest.version, 

238 sorted(manifest.hooks), 

239 sorted(manifest.capabilities), 

240 ) 

241 

242 def register_core_handler( 

243 self, 

244 hook: str, 

245 handler: Callable[..., Any], 

246 *, 

247 name: str, 

248 capabilities: frozenset[str] | None = None, 

249 ) -> None: 

250 """Register a core handler with an explicit sortable name. 

251 

252 Use names such as ``core.001.sanitize`` and ``core.002.cid`` when 

253 relative ordering among core handlers matters. 

254 """ 

255 ctx = PluginContext( 

256 plugin_name="core", 

257 plugin_version="0.0.0", 

258 capabilities=capabilities or frozenset(), 

259 core_apis=self._core_apis, 

260 ) 

261 with self._lock: 

262 self._ensure_mutable() 

263 self._add_handler( 

264 hook=hook, 

265 handler=handler, 

266 plugin_name="core", 

267 handler_name=name, 

268 ctx=ctx, 

269 is_core=True, 

270 async_safe=True, 

271 ) 

272 

273 def fire_voting(self, hook: str, **kwargs: Any) -> VotingDecision: 

274 self._ensure_semantic(hook, HookSemantic.VOTING) 

275 with self._observe_hook(hook): 

276 for entry in self._handlers[hook]: 

277 try: 

278 result = self._invoke_handler(entry, **kwargs) 

279 except RejectError as exc: 

280 self._metric_inc(PLUGIN_VOTING_DECISION, hook=hook, decision="deny") 

281 self._emit_handler_denied(entry, reason=exc.reason) 

282 return Deny(exc.reason) 

283 except Exception as exc: 

284 self._emit_handler_error(entry, exc) 

285 raise PluginExecutionError( 

286 f"handler {entry.handler_name!r} failed for hook {hook!r}: {exc}" 

287 ) from exc 

288 if result is None: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true

289 raise PluginExecutionError( 

290 f"handler {entry.handler_name!r} returned None; expected VotingDecision" 

291 ) 

292 if isinstance(result, Deny): 

293 self._metric_inc(PLUGIN_VOTING_DECISION, hook=hook, decision="deny") 

294 self._emit_handler_denied(entry, reason=result.reason) 

295 return result 

296 if not isinstance(result, Allow): 

297 raise PluginExecutionError( 

298 f"handler {entry.handler_name!r} returned {type(result).__name__}; " 

299 "expected Allow or Deny" 

300 ) 

301 self._metric_inc(PLUGIN_VOTING_DECISION, hook=hook, decision="allow") 

302 return ALLOW_SINGLETON 

303 

304 def fire_filter_chain(self, hook: str, value: T, **kwargs: Any) -> T: 

305 self._ensure_semantic(hook, HookSemantic.FILTER_CHAIN) 

306 with self._observe_hook(hook): 

307 result: T = value 

308 for entry in self._handlers[hook]: 

309 try: 

310 next_result = self._invoke_handler(entry, result, **kwargs) 

311 except Exception as exc: 

312 self._emit_handler_error(entry, exc) 

313 raise PluginExecutionError( 

314 f"handler {entry.handler_name!r} failed for hook {hook!r}: {exc}" 

315 ) from exc 

316 if next_result is None: 

317 raise PluginExecutionError( 

318 f"handler {entry.handler_name!r} returned None for " 

319 f"filter-chain hook {hook!r}" 

320 ) 

321 result = cast(T, next_result) 

322 return result 

323 

324 def fire_score_delta( 

325 self, hook: str, scored_results: list[Any], **kwargs: Any 

326 ) -> dict[str, float]: 

327 self._ensure_semantic(hook, HookSemantic.SCORE_DELTA) 

328 with self._observe_hook(hook): 

329 combined: dict[str, float] = {} 

330 for entry in self._handlers[hook]: 

331 try: 

332 deltas = self._invoke_handler(entry, scored_results, **kwargs) 

333 except Exception as exc: 

334 self._emit_handler_error(entry, exc) 

335 raise PluginExecutionError( 

336 f"handler {entry.handler_name!r} failed for hook {hook!r}: {exc}" 

337 ) from exc 

338 if not isinstance(deltas, dict): 

339 raise PluginExecutionError( 

340 f"handler {entry.handler_name!r} returned {type(deltas).__name__}; " 

341 "expected dict[str, float]" 

342 ) 

343 for fact_id, delta in deltas.items(): 

344 combined[str(fact_id)] = combined.get(str(fact_id), 0.0) + float(delta) 

345 return combined 

346 

347 def fire_fire_and_forget(self, hook: str, **kwargs: Any) -> None: 

348 spec = self._ensure_semantic(hook, HookSemantic.FIRE_AND_FORGET) 

349 with self._observe_hook(hook): 

350 for entry in self._handlers[hook]: 

351 try: 

352 self._invoke_handler(entry, **kwargs) 

353 except Exception as exc: 

354 logger.warning( 

355 "handler %r failed for hook %r", entry.handler_name, hook, exc_info=True 

356 ) 

357 self._emit_handler_error(entry, exc) 

358 if spec.strict_audit: 

359 raise PluginExecutionError( 

360 f"handler {entry.handler_name!r} failed for audit hook {hook!r}: {exc}" 

361 ) from exc 

362 

363 def handlers_for(self, hook: str) -> tuple[HandlerEntry, ...]: 

364 self._require_known_hook(hook) 

365 return self._handlers[hook] 

366 

367 def registered_plugins(self) -> frozenset[str]: 

368 return frozenset(self._plugin_names) 

369 

370 def plugin_registration_order(self) -> tuple[str, ...]: 

371 return tuple(self._plugin_order) 

372 

373 def plugin_versions(self) -> dict[str, str]: 

374 return dict(self._plugin_versions) 

375 

376 def plugin_infos(self) -> tuple[PluginInfo, ...]: 

377 return tuple(self._plugin_infos[name] for name in self._plugin_order) 

378 

379 def plugin_info(self, name: str) -> PluginInfo | None: 

380 return self._plugin_infos.get(name) 

381 

382 def plugin_signing_metadata(self, name: str) -> dict[str, Any]: 

383 return dict(self._plugin_signing_metadata.get(name, {})) 

384 

385 def development_unsigned_plugins(self) -> tuple[str, ...]: 

386 return tuple( 

387 name 

388 for name in self._plugin_order 

389 if self._plugin_signing_metadata.get(name, {}).get("trust_decision") 

390 == "development_unsigned_override" 

391 ) 

392 

393 def poll_plugin_health(self) -> tuple[PluginHealthReport, ...]: 

394 """Run lifecycle health checks and record the latest report per plugin. 

395 

396 Health is informational for PR 4-INF.2; unhealthy plugins remain 

397 registered and their handlers stay active until a future policy layer 

398 chooses otherwise. 

399 """ 

400 reports = tuple(self._poll_one_plugin_health(name) for name in self._plugin_order) 

401 self._plugin_health_reports.update({report.plugin_name: report for report in reports}) 

402 return reports 

403 

404 def plugin_health_reports(self) -> tuple[PluginHealthReport, ...]: 

405 return tuple( 

406 report 

407 for name in self._plugin_order 

408 if (report := self._plugin_health_reports.get(name)) is not None 

409 ) 

410 

411 def freeze(self) -> None: 

412 """Reject subsequent startup-time mutations. 

413 

414 Handler collections are stored as tuples throughout registration; freeze 

415 records the startup boundary so runtime dispatch remains read-only. 

416 """ 

417 with self._lock: 

418 self._frozen = True 

419 if self._timeout_executor is None and self._has_timeout_handlers(): 419 ↛ 420line 419 didn't jump to line 420 because the condition on line 419 was never true

420 self._timeout_executor = ThreadPoolExecutor( 

421 max_workers=max(1, self._timeout_handler_count()), 

422 thread_name_prefix="stigmem-plugin-timeout", 

423 ) 

424 

425 def _add_handler( 

426 self, 

427 *, 

428 hook: str, 

429 handler: Callable[..., Any], 

430 plugin_name: str, 

431 handler_name: str, 

432 ctx: PluginContext, 

433 is_core: bool, 

434 async_safe: bool, 

435 ) -> None: 

436 self._require_known_hook(hook) 

437 entry = HandlerEntry( 

438 hook=hook, 

439 handler=handler, 

440 plugin_name=plugin_name, 

441 handler_name=handler_name, 

442 ctx=ctx, 

443 is_core=is_core, 

444 async_safe=async_safe, 

445 timeout_seconds=self._timeout_for_handler(handler), 

446 ) 

447 existing = list(self._handlers[hook]) 

448 existing.append(entry) 

449 existing.sort(key=lambda item: self._sort_key(hook, item)) 

450 self._handlers[hook] = tuple(existing) 

451 self._metric_set(PLUGIN_HANDLERS_PER_HOOK, len(self._handlers[hook]), hook=hook) 

452 

453 def _invoke_handler(self, entry: HandlerEntry, *args: Any, **kwargs: Any) -> Any: 

454 timeout_seconds = entry.timeout_seconds 

455 if timeout_seconds is not None: 

456 return self._invoke_handler_with_timeout(entry, timeout_seconds, *args, **kwargs) 

457 if not self._emit_metrics: 

458 return entry.handler(entry.ctx, *args, **kwargs) 

459 self._metric_inc(PLUGIN_HANDLER_INVOCATION, hook=entry.hook, plugin=entry.plugin_name) 

460 start = time.perf_counter() 

461 try: 

462 return entry.handler(entry.ctx, *args, **kwargs) 

463 except Exception as exc: 

464 self._metric_inc( 

465 PLUGIN_HANDLER_ERROR, 

466 hook=entry.hook, 

467 plugin=entry.plugin_name, 

468 error_type=type(exc).__name__, 

469 ) 

470 raise 

471 finally: 

472 self._metric_observe( 

473 PLUGIN_HANDLER_DURATION, 

474 time.perf_counter() - start, 

475 hook=entry.hook, 

476 plugin=entry.plugin_name, 

477 ) 

478 

479 def _invoke_handler_with_timeout( 

480 self, entry: HandlerEntry, timeout_seconds: float, *args: Any, **kwargs: Any 

481 ) -> Any: 

482 executor = self._timeout_executor 

483 if executor is None: 483 ↛ 489line 483 didn't jump to line 489 because the condition on line 483 was always true

484 executor = ThreadPoolExecutor( 

485 max_workers=1, 

486 thread_name_prefix="stigmem-plugin-timeout", 

487 ) 

488 self._timeout_executor = executor 

489 self._metric_inc(PLUGIN_HANDLER_INVOCATION, hook=entry.hook, plugin=entry.plugin_name) 

490 start = time.perf_counter() 

491 future = executor.submit(entry.handler, entry.ctx, *args, **kwargs) 

492 try: 

493 return future.result(timeout=timeout_seconds) 

494 except FutureTimeoutError as exc: 

495 future.cancel() 

496 timeout_exc = PluginExecutionError( 

497 f"handler {entry.handler_name!r} timed out for hook {entry.hook!r} " 

498 f"after {timeout_seconds:.3f}s" 

499 ) 

500 self._metric_inc( 

501 PLUGIN_HANDLER_ERROR, 

502 hook=entry.hook, 

503 plugin=entry.plugin_name, 

504 error_type="timeout", 

505 ) 

506 raise timeout_exc from exc 

507 except Exception as exc: 

508 self._metric_inc( 

509 PLUGIN_HANDLER_ERROR, 

510 hook=entry.hook, 

511 plugin=entry.plugin_name, 

512 error_type=type(exc).__name__, 

513 ) 

514 raise 

515 finally: 

516 self._metric_observe( 

517 PLUGIN_HANDLER_DURATION, 

518 time.perf_counter() - start, 

519 hook=entry.hook, 

520 plugin=entry.plugin_name, 

521 ) 

522 

523 @contextlib.contextmanager 

524 def _observe_hook(self, hook: str) -> Any: 

525 if not self._emit_metrics: 

526 yield 

527 return 

528 self._metric_inc(PLUGIN_HOOK_FIRE, hook=hook) 

529 start = time.perf_counter() 

530 try: 

531 yield 

532 finally: 

533 self._metric_observe(PLUGIN_HOOK_DURATION, time.perf_counter() - start, hook=hook) 

534 

535 def _metric_inc(self, metric: Any, **labels: str) -> None: 

536 if not self._emit_metrics: 

537 return 

538 try: 

539 metric.labels(**labels).inc() 

540 except Exception as exc: 

541 logger.debug("failed to increment plugin metric %s: %s", metric, exc) 

542 

543 def _metric_observe(self, metric: Any, value: float, **labels: str) -> None: 

544 if not self._emit_metrics: 

545 return 

546 try: 

547 metric.labels(**labels).observe(value) 

548 except Exception as exc: 

549 logger.debug("failed to observe plugin metric %s: %s", metric, exc) 

550 

551 def _metric_set(self, metric: Any, value: int, **labels: str) -> None: 

552 if not self._emit_metrics: 

553 return 

554 try: 

555 if labels: 

556 metric.labels(**labels).set(value) 

557 else: 

558 metric.set(value) 

559 except Exception as exc: 

560 logger.debug("failed to set plugin metric %s: %s", metric, exc) 

561 

562 def _emit_handler_denied(self, entry: HandlerEntry, *, reason: str) -> None: 

563 self._emit_registry_audit( 

564 "plugin.handler_denied", 

565 plugin_name=entry.plugin_name, 

566 target_uri=f"plugin:{entry.plugin_name}", 

567 metadata={ 

568 "plugin_name": entry.plugin_name, 

569 "hook": entry.hook, 

570 "handler_name": entry.handler_name, 

571 "reason": reason, 

572 }, 

573 failure_reason=reason, 

574 ) 

575 

576 def _emit_handler_error(self, entry: HandlerEntry, exc: Exception) -> None: 

577 self._emit_registry_audit( 

578 "plugin.handler_error", 

579 plugin_name=entry.plugin_name, 

580 target_uri=f"plugin:{entry.plugin_name}", 

581 metadata={ 

582 "plugin_name": entry.plugin_name, 

583 "hook": entry.hook, 

584 "handler_name": entry.handler_name, 

585 "error_type": type(exc).__name__, 

586 "error": str(exc), 

587 }, 

588 failure_reason=str(exc), 

589 exception_type=type(exc).__name__, 

590 ) 

591 

592 def _emit_registry_audit( 

593 self, 

594 event_type: str, 

595 *, 

596 manifest: PluginManifest | None = None, 

597 plugin_name: str | None = None, 

598 target_uri: str | None = None, 

599 reason: str | None = None, 

600 validation_failure: str | None = None, 

601 metadata: dict[str, Any] | None = None, 

602 failure_reason: str | None = None, 

603 exception_type: str | None = None, 

604 ) -> None: 

605 if self._emitting_registry_audit: 

606 return 

607 name = plugin_name or (manifest.name if manifest is not None else "unknown") 

608 event_metadata = dict(metadata or {}) 

609 if manifest is not None: 

610 event_metadata.update( 

611 { 

612 "plugin_name": manifest.name, 

613 "version": manifest.version, 

614 "capabilities": sorted(manifest.capabilities), 

615 "hooks": sorted(manifest.hooks), 

616 "async_safe": manifest.async_safe, 

617 "signed_by": event_metadata.get("signed_by", "unsigned"), 

618 "requires_stigmem": manifest.requires_stigmem, 

619 } 

620 ) 

621 if reason is not None: 

622 event_metadata["reason"] = reason 

623 if validation_failure is not None: 

624 event_metadata["validation_failure"] = validation_failure 

625 outcome = ( 

626 Failure( 

627 reason=failure_reason or reason or "plugin audit event", 

628 exception_type=exception_type, 

629 ) 

630 if event_type.endswith("_failed") or failure_reason is not None 

631 else Success() 

632 ) 

633 self._emitting_registry_audit = True 

634 try: 

635 self.fire_fire_and_forget( 

636 "audit_emit", 

637 event=AuditEvent( 

638 event_type=event_type, 

639 actor_uri="system:plugin-registry", 

640 target_uri=target_uri or f"plugin:{name}", 

641 tenant_id="system", 

642 timestamp=datetime.now(UTC), 

643 outcome=outcome, 

644 metadata=event_metadata, 

645 ), 

646 ) 

647 except Exception: 

648 logger.warning( 

649 "failed to emit plugin registry audit event %r", event_type, exc_info=True 

650 ) 

651 finally: 

652 self._emitting_registry_audit = False 

653 

654 def _sort_key(self, hook: str, entry: HandlerEntry) -> tuple[int, str]: 

655 policy = HOOK_SPECS[hook].ordering 

656 if policy in (HookOrdering.CORE_FIRST, HookOrdering.CORE_ONLY_DEFAULT): 

657 partition = 0 if entry.is_core else 1 

658 elif policy == HookOrdering.PLUGINS_FIRST: 

659 partition = 1 if entry.is_core else 0 

660 else: 

661 partition = 0 

662 return partition, entry.handler_name 

663 

664 def _timeout_for_handler(self, handler: Callable[..., Any]) -> float | None: 

665 timeout = getattr(handler, "__plugin_timeout__", None) 

666 if timeout is None: 

667 timeout = self._handler_timeout_seconds 

668 if timeout is None: 

669 return None 

670 return _validate_timeout_seconds(timeout) 

671 

672 def _has_timeout_handlers(self) -> bool: 

673 return any( 

674 entry.timeout_seconds is not None 

675 for entries in self._handlers.values() 

676 for entry in entries 

677 ) 

678 

679 def _timeout_handler_count(self) -> int: 

680 return sum( 

681 1 

682 for entries in self._handlers.values() 

683 for entry in entries 

684 if entry.timeout_seconds is not None 

685 ) 

686 

687 def _poll_one_plugin_health(self, plugin_name: str) -> PluginHealthReport: 

688 checked_at = datetime.now(UTC) 

689 version = self._plugin_versions[plugin_name] 

690 checker = self._plugin_health_checks.get(plugin_name) 

691 if checker is None: 

692 return PluginHealthReport( 

693 plugin_name=plugin_name, 

694 plugin_version=version, 

695 status=PluginHealthStatus.UNKNOWN, 

696 message="no health check registered", 

697 checked_at=checked_at, 

698 ) 

699 try: 

700 result = checker(self._plugin_contexts[plugin_name]) 

701 except Exception as exc: 

702 return PluginHealthReport( 

703 plugin_name=plugin_name, 

704 plugin_version=version, 

705 status=PluginHealthStatus.UNHEALTHY, 

706 message=str(exc), 

707 checked_at=checked_at, 

708 error_summary=f"{type(exc).__name__}: {exc}", 

709 ) 

710 if not isinstance(result, PluginHealth): 710 ↛ 711line 710 didn't jump to line 711 because the condition on line 710 was never true

711 return PluginHealthReport( 

712 plugin_name=plugin_name, 

713 plugin_version=version, 

714 status=PluginHealthStatus.UNHEALTHY, 

715 message=f"health_check returned {type(result).__name__}; expected PluginHealth", 

716 checked_at=checked_at, 

717 error_summary=f"invalid result: {type(result).__name__}", 

718 ) 

719 return PluginHealthReport( 

720 plugin_name=plugin_name, 

721 plugin_version=version, 

722 status=result.status, 

723 message=result.message, 

724 checked_at=checked_at, 

725 ) 

726 

727 def _validate_manifest_compatibility(self, manifest: PluginManifest) -> None: 

728 try: 

729 specifier = SpecifierSet(manifest.requires_stigmem) 

730 except InvalidSpecifier as exc: 

731 raise ManifestError( 

732 f"plugin {manifest.name!r} has invalid requires_stigmem " 

733 f"{manifest.requires_stigmem!r}: {exc}" 

734 ) from exc 

735 current_version = Version(_current_stigmem_version()) 

736 if not specifier.contains(current_version, prereleases=True): 

737 raise ManifestError( 

738 f"plugin {manifest.name!r} requires stigmem {manifest.requires_stigmem!r}, " 

739 f"but current version is {current_version}" 

740 ) 

741 

742 def _validate_manifest_handler_signatures(self, manifest: PluginManifest) -> None: 

743 for hook, handler in manifest.hooks.items(): 

744 semantic = HOOK_SPECS[hook].semantic 

745 required_positional = ( 

746 2 

747 if semantic 

748 in ( 

749 HookSemantic.FILTER_CHAIN, 

750 HookSemantic.SCORE_DELTA, 

751 ) 

752 else 1 

753 ) 

754 if not _accepts_positional_args(handler, required_positional): 

755 raise ManifestError( 

756 f"handler for hook {hook!r} on plugin {manifest.name!r} must accept " 

757 f"at least {required_positional} positional argument" 

758 f"{'' if required_positional == 1 else 's'}" 

759 ) 

760 

761 def _ensure_mutable(self) -> None: 

762 if self._frozen: 

763 raise RegistryFrozenError("registry is frozen; cannot register handlers") 

764 

765 def _ensure_semantic(self, hook: str, semantic: HookSemantic) -> Any: 

766 spec = self._require_known_hook(hook) 

767 if spec.semantic != semantic: 767 ↛ 768line 767 didn't jump to line 768 because the condition on line 767 was never true

768 raise PluginExecutionError( 

769 f"hook {hook!r} has semantic {spec.semantic}; cannot fire as {semantic}" 

770 ) 

771 return spec 

772 

773 def _require_known_hook(self, hook: str) -> Any: 

774 try: 

775 return HOOK_SPECS[hook] 

776 except KeyError as exc: 

777 raise ManifestError(f"unknown hook {hook!r}") from exc 

778 

779 

780_REGISTRY = HookRegistry() 

781 

782 

783def get_registry() -> HookRegistry: 

784 return _REGISTRY 

785 

786 

787def set_registry(registry: HookRegistry) -> HookRegistry: 

788 """Replace the process registry, returning the previous instance.""" 

789 global _REGISTRY 

790 previous = _REGISTRY 

791 _REGISTRY = registry 

792 return previous 

793 

794 

795def register_core_handler( 

796 hook: str, 

797 handler: Callable[..., Any], 

798 *, 

799 name: str, 

800 capabilities: frozenset[str] | None = None, 

801) -> None: 

802 get_registry().register_core_handler(hook, handler, name=name, capabilities=capabilities) 

803 

804 

805def _current_stigmem_version() -> str: 

806 try: 

807 from stigmem_node import __version__ 

808 

809 return __version__ 

810 except ImportError: 

811 pass 

812 try: 

813 return version("stigmem-node") 

814 except PackageNotFoundError: 

815 return _FALLBACK_STIGMEM_VERSION 

816 

817 

818def _registration_audit_metadata( 

819 *, 

820 discovery_source: dict[str, Any] | None, 

821 signing_identity: str, 

822 signing_metadata: dict[str, Any] | None = None, 

823) -> dict[str, Any]: 

824 metadata = { 

825 "discovery_source": discovery_source or {"type": "manual"}, 

826 "signed_by": signing_identity, 

827 } 

828 if signing_metadata is not None: 

829 metadata["signing"] = dict(signing_metadata) 

830 return metadata 

831 

832 

833def _accepts_positional_args(handler: Callable[..., Any], required_count: int) -> bool: 

834 try: 

835 signature = inspect.signature(handler) 

836 except (TypeError, ValueError): 

837 return False 

838 positional_count = 0 

839 for parameter in signature.parameters.values(): 

840 if parameter.kind == inspect.Parameter.VAR_POSITIONAL: 

841 return True 

842 if parameter.kind in ( 

843 inspect.Parameter.POSITIONAL_ONLY, 

844 inspect.Parameter.POSITIONAL_OR_KEYWORD, 

845 ): 

846 positional_count += 1 

847 return positional_count >= required_count 

848 

849 

850def _validate_timeout_seconds(value: float) -> float: 

851 timeout = float(value) 

852 if timeout <= 0: 852 ↛ 853line 852 didn't jump to line 853 because the condition on line 852 was never true

853 raise ValueError("handler timeout must be positive") 

854 if timeout > 30: 854 ↛ 855line 854 didn't jump to line 855 because the condition on line 854 was never true

855 raise ValueError("handler timeout cannot exceed 30 seconds") 

856 return timeout