config.py python
1,235 lines 46.1 KB
Raw
sha256:8461d44b77376fbf06fa7c3e085d309e3010fd8d5886d63c63e69ce118811ad4 docs: record AFF-b feature-tip SHAs after AFF-b-ISR commit. Human 4 days ago
1 """`.overseer/config.yaml` schema validation — fail-closed on unknown version/regime."""
2
3 from __future__ import annotations
4
5 from dataclasses import dataclass
6 from pathlib import Path
7 from typing import Any
8
9 import yaml
10
11 from adapters.errors import ConfigError
12
13 SUPPORTED_CONFIG_VERSION = 1
14 SUPPORTED_REGIMES = frozenset({"muse+git-mirror", "muse-only", "git-only"})
15 SUPPORTED_CANONICAL = frozenset({"muse", "git"})
16 REVIEWER_MODES = frozenset({"agent", "human"})
17 REVIEWER_PROVIDERS = frozenset({"local", "api"})
18 REVIEWER_FALLBACK = frozenset({"human"})
19 HUMAN_ESCALATION_TOKENS = frozenset({"security", "irreversible", "real_money", "gates_tier3"})
20 DEFAULT_REVIEWER_MODEL = "thinking-high"
21 DEFAULT_REVIEWER_PROVIDER = "local"
22 DEFAULT_REVIEWER_FALLBACK = "human"
23 REVIEWER_MAPPING_KEYS = frozenset({"mode", "model", "provider", "fallback"})
24 CHECKPOINTS_KEYS = frozenset(
25 {
26 "enabled",
27 "policy",
28 "active_manifest",
29 "progress",
30 "orchestrator",
31 "allow_hand_verified",
32 }
33 )
34 HONESTY_KEYS = frozenset(
35 {
36 "enabled",
37 "ledger",
38 "roles_file",
39 "require_verdict_on",
40 "require_l1_evidence",
41 "require_verification_evidence",
42 "require_deploy_health",
43 "require_independent_second_reviewer",
44 "adversarial_freeze",
45 "allow_signed_approval",
46 "ci_reexecutor",
47 "require_agent_signature",
48 }
49 )
50 MODULES_GOVERNANCE_KEYS = frozenset({"enabled"})
51 MODULES_CHECKPOINTS_KEYS = frozenset({"enabled"})
52 MODULES_HONESTY_KEYS = frozenset({"enabled"})
53 EXTENSION_KEYS = frozenset({"id", "schema_version", "config_path"})
54 HOOK_NAMES = frozenset({"board_done", "handoff", "register"})
55 GOVERNANCE_GATES_SURFACES = frozenset({"status", "governance-sync", "handover-paste"})
56 GOVERNANCE_GATES_KEYS = frozenset(
57 {
58 "remind",
59 "freeze_review",
60 "build_verification",
61 "surfaces",
62 }
63 )
64 L1_EVIDENCE_MODES = frozenset({"off", "warn", "require"})
65 ADVERSARIAL_FREEZE_MODES = frozenset({"off", "suggest", "require"})
66 MODEL_ROUTING_KEYS = frozenset({"enabled", "policy"})
67 DEFAULT_MODEL_ROUTING_POLICY = "policy/model-routing.yaml"
68 COST_AWARENESS_SURFACES = frozenset({"status", "governance-sync"})
69 COST_AWARENESS_KEYS = frozenset({"enabled", "surfaces"})
70 SESSION_BOOKENDS_KEYS = frozenset({"enabled"})
71 WORKSPACE_KEYS = frozenset({"constellation_id", "product_order_root", "manifest"})
72
73 # Hosted dashboard keys live in tools.hosted_dashboard.config (import deferred to avoid cycles).
74
75
76 @dataclass(frozen=True)
77 class GitConfig:
78 remote: str
79 main_branch: str
80 mirror_branch: str | None
81 feature_branch_pattern: str
82
83
84 @dataclass(frozen=True)
85 class MuseConfig:
86 staging_remote: str | None
87 main_branch: str | None
88 working_dir: str | None = None
89
90
91 @dataclass(frozen=True)
92 class LaneDocsConfig:
93 """One handover + roadmap pair (K8 multi-lane)."""
94
95 handover: str
96 roadmap: str
97 handover_title: str
98 roadmap_title: str
99
100
101 @dataclass(frozen=True)
102 class DocsConfig:
103 handover: str
104 roadmap: str
105 coordination: str | None
106 standing_decisions: str
107 handover_title: str
108 roadmap_title: str
109 default_lane: str | None = None
110 lanes: dict[str, LaneDocsConfig] | None = None
111
112
113 @dataclass(frozen=True)
114 class ThresholdsConfig:
115 realign_max_commits: int
116 drift_warn_only: bool
117
118
119 @dataclass(frozen=True)
120 class ReviewerConfig:
121 mode: str
122 model: str
123 provider: str
124 fallback: str
125
126
127 @dataclass(frozen=True)
128 class FreezeContractConfig:
129 enabled: bool
130 reviewer: ReviewerConfig
131 human_escalation: list[str]
132
133
134 @dataclass(frozen=True)
135 class RepoConfig:
136 name: str
137 root_relative_docs: str
138
139
140 @dataclass(frozen=True)
141 class VcsConfig:
142 regime: str
143 canonical: str
144 git: GitConfig
145 muse: MuseConfig
146
147
148 @dataclass(frozen=True)
149 class CheckpointsConfig:
150 """L1 checkpoint module settings (§K9.2)."""
151
152 enabled: bool = False
153 policy: str | None = None
154 active_manifest: str | None = None
155 progress: str | None = None
156 orchestrator: str | None = None
157 allow_hand_verified: bool = False
158
159
160 @dataclass(frozen=True)
161 class HonestyConfig:
162 """L2 honesty module settings (§K9.2) — parsed for forward compat; K10 builds CLI."""
163
164 enabled: bool = False
165 ledger: str | None = None
166 roles_file: str | None = None
167 require_verdict_on: frozenset[str] = frozenset({"board_done", "handoff", "register"})
168 require_l1_evidence: str = "warn"
169 require_verification_evidence: str = "off"
170 require_deploy_health: str = "off"
171 require_independent_second_reviewer: str = "require"
172 adversarial_freeze: str = "suggest"
173 allow_signed_approval: bool = False
174 ci_reexecutor: str | None = None
175 require_agent_signature: bool = False
176
177
178 @dataclass(frozen=True)
179 class ModulesConfig:
180 """Optional mirror of section enable flags (§K9.2)."""
181
182 governance_enabled: bool = True
183 checkpoints_enabled: bool | None = None
184 honesty_enabled: bool | None = None
185
186
187 @dataclass(frozen=True)
188 class GovernanceGatesConfig:
189 """Governance gate reminder settings (§KH1.9)."""
190
191 remind: bool = True
192 freeze_review_required: bool = True
193 build_verification_required: bool = True
194 surfaces: frozenset[str] = frozenset(GOVERNANCE_GATES_SURFACES)
195
196
197 @dataclass(frozen=True)
198 class ModelRoutingConfig:
199 """Optional model-routing policy settings (§PR.5)."""
200
201 enabled: bool = False
202 policy: str = DEFAULT_MODEL_ROUTING_POLICY
203
204
205 @dataclass(frozen=True)
206 class CostAwarenessConfig:
207 """Optional cost-awareness surface settings (§PC.6)."""
208
209 enabled: bool = False
210 surfaces: frozenset[str] = frozenset(COST_AWARENESS_SURFACES)
211
212
213 @dataclass(frozen=True)
214 class SessionBookendsConfig:
215 """Optional Cursor session bookend hooks (§LT.4.2)."""
216
217 enabled: bool = False
218
219
220 @dataclass(frozen=True)
221 class WorkspacePointerConfig:
222 """Additive constellation pointer (§MR.4.2). Omitted = single-repo only."""
223
224 constellation_id: str
225 product_order_root: str | None = None
226 manifest: str | None = None
227
228
229 @dataclass(frozen=True)
230 class HostedDashboardSection:
231 """Optional hosted_dashboard settings (§HGD.10.2) — opaque validated mapping holder."""
232
233 # Parsed by tools.hosted_dashboard.config; stored as validated object.
234 enabled: bool = False
235 allow_non_loopback: bool = False
236 cors_origins: tuple[str, ...] = ()
237 org_allowlist: tuple[str, ...] = ()
238 github_contents: bool = True
239 github_meta: bool = True
240 github_checks_advisory: bool = False
241 musehub_read: bool = False
242
243
244 @dataclass(frozen=True)
245 class PostLandSyncConfig:
246 """Optional ff-only local-main sync after a successful ``ok pr-land`` (§PLS.3).
247
248 v1 closed vocabulary: ``strategy`` must be ``ff_only`` and
249 ``require_clean_worktree`` must be ``true`` — dirty-tree clobber is a non-goal.
250 Remote/branch identity comes from ``vcs.git.*`` (never duplicated here).
251 """
252
253 enabled: bool = False
254 strategy: str = "ff_only"
255 require_clean_worktree: bool = True
256
257
258 @dataclass(frozen=True)
259 class CloseRitualConfig:
260 """Fail-closed land-to-main hygiene (never auto-merges — Tier 3).
261
262 ``verify_landed`` compares ``require_paths`` working-tree content to
263 ``origin/<main_branch>``. ``prepare_pr`` only reports dirty/uncommitted state.
264 ``post_land_sync`` is the additive PLS post-step on ``ok pr-land`` only.
265 """
266
267 enabled: bool = False
268 mode: str = "verify_landed" # verify_landed | prepare_pr
269 require_paths: tuple[str, ...] = ()
270 consumer_verify_script: str | None = None # optional repo-relative script
271 post_land_sync: PostLandSyncConfig = PostLandSyncConfig()
272
273
274 @dataclass(frozen=True)
275 class ExtensionEntry:
276 """One extensions[] escape-hatch entry (§K9.2)."""
277
278 id: str
279 schema_version: int
280 config_path: str
281
282
283 @dataclass(frozen=True)
284 class OverseerConfig:
285 overseer_config_version: int
286 repo: RepoConfig
287 vcs: VcsConfig
288 docs: DocsConfig
289 thresholds: ThresholdsConfig
290 freeze_contract: FreezeContractConfig
291 checkpoints: CheckpointsConfig = CheckpointsConfig()
292 honesty: HonestyConfig = HonestyConfig()
293 modules: ModulesConfig | None = None
294 extensions: tuple[ExtensionEntry, ...] = ()
295 extension_warnings: tuple[str, ...] = ()
296 governance_gates: GovernanceGatesConfig = GovernanceGatesConfig()
297 model_routing: ModelRoutingConfig = ModelRoutingConfig()
298 cost_awareness: CostAwarenessConfig = CostAwarenessConfig()
299 session_bookends: SessionBookendsConfig = SessionBookendsConfig()
300 hosted_dashboard: HostedDashboardSection = HostedDashboardSection()
301 close_ritual: CloseRitualConfig = CloseRitualConfig()
302 workspace: WorkspacePointerConfig | None = None
303
304
305 def load_config(path: Path) -> OverseerConfig:
306 """Parse and validate config; raise ``ConfigError`` on any violation."""
307 path = path.resolve()
308 if not path.is_file():
309 raise ConfigError("config file missing", str(path))
310
311 try:
312 raw_text = path.read_text(encoding="utf-8")
313 except OSError as exc:
314 raise ConfigError(f"cannot read config: {exc}", str(path)) from exc
315
316 try:
317 raw = yaml.safe_load(raw_text)
318 except yaml.YAMLError as exc:
319 raise ConfigError(f"unparseable YAML: {exc}", str(path)) from exc
320
321 if not isinstance(raw, dict):
322 raise ConfigError("config root must be a mapping", str(path))
323
324 return _validate_config(raw, str(path))
325
326
327 def _require_mapping(data: Any, field: str, path: str) -> dict[str, Any]:
328 if not isinstance(data, dict):
329 raise ConfigError(f"{field} must be a mapping", path)
330 return data
331
332
333 def _require_str(data: dict[str, Any], field: str, path: str) -> str:
334 value = data.get(field)
335 if not isinstance(value, str) or not value.strip():
336 raise ConfigError(f"{field} must be a non-empty string", path)
337 return value
338
339
340 def _optional_str(data: dict[str, Any], field: str) -> str | None:
341 value = data.get(field)
342 if value is None:
343 return None
344 if not isinstance(value, str):
345 raise ConfigError(f"{field} must be a string or null")
346 return value
347
348
349 def _validate_config(raw: dict[str, Any], path: str) -> OverseerConfig:
350 version = raw.get("overseer_config_version")
351 if not isinstance(version, int):
352 raise ConfigError("overseer_config_version must be an integer", path)
353 if version != SUPPORTED_CONFIG_VERSION:
354 raise ConfigError(
355 f"unsupported overseer_config_version {version} "
356 f"(supported: {SUPPORTED_CONFIG_VERSION})",
357 path,
358 )
359
360 repo_raw = _require_mapping(raw.get("repo"), "repo", path)
361 repo = RepoConfig(
362 name=_require_str(repo_raw, "name", path),
363 root_relative_docs=_require_str(repo_raw, "root_relative_docs", path),
364 )
365
366 vcs_raw = _require_mapping(raw.get("vcs"), "vcs", path)
367 regime = _require_str(vcs_raw, "regime", path)
368 if regime not in SUPPORTED_REGIMES:
369 raise ConfigError(
370 f"unsupported vcs.regime {regime!r} (supported: {sorted(SUPPORTED_REGIMES)})",
371 path,
372 )
373
374 canonical = _require_str(vcs_raw, "canonical", path)
375 if canonical not in SUPPORTED_CANONICAL:
376 raise ConfigError(f"unsupported vcs.canonical {canonical!r}", path)
377
378 git_raw = _require_mapping(vcs_raw.get("git"), "vcs.git", path)
379 muse_raw = _require_mapping(vcs_raw.get("muse"), "vcs.muse", path)
380
381 git = GitConfig(
382 remote=_require_str(git_raw, "remote", path),
383 main_branch=_require_str(git_raw, "main_branch", path),
384 mirror_branch=_optional_str(git_raw, "mirror_branch"),
385 feature_branch_pattern=_require_str(git_raw, "feature_branch_pattern", path),
386 )
387 muse = MuseConfig(
388 staging_remote=_optional_str(muse_raw, "staging_remote"),
389 main_branch=_optional_str(muse_raw, "main_branch"),
390 working_dir=_optional_str(muse_raw, "working_dir"),
391 )
392 _validate_muse_working_dir_shape(muse.working_dir, path)
393
394 _validate_regime_fields(regime, canonical, git, muse, path)
395
396 docs_raw = _require_mapping(raw.get("docs"), "docs", path)
397 lanes = _parse_lanes(docs_raw.get("lanes"), path)
398 default_lane = _optional_str(docs_raw, "default_lane")
399 handover = _require_str(docs_raw, "handover", path)
400 roadmap = _require_str(docs_raw, "roadmap", path)
401 handover_title = _optional_str(docs_raw, "handover_title") or "Overseer Handover"
402 roadmap_title = _optional_str(docs_raw, "roadmap_title") or "Roadmap"
403 _validate_lanes_config(
404 lanes=lanes,
405 default_lane=default_lane,
406 handover=handover,
407 roadmap=roadmap,
408 handover_title=handover_title,
409 roadmap_title=roadmap_title,
410 path=path,
411 )
412 docs = DocsConfig(
413 handover=handover,
414 roadmap=roadmap,
415 coordination=_optional_str(docs_raw, "coordination"),
416 standing_decisions=_require_str(docs_raw, "standing_decisions", path),
417 handover_title=handover_title,
418 roadmap_title=roadmap_title,
419 default_lane=default_lane,
420 lanes=lanes,
421 )
422
423 thresholds_raw = _require_mapping(raw.get("thresholds"), "thresholds", path)
424 realign_max = thresholds_raw.get("realign_max_commits")
425 if not isinstance(realign_max, int) or realign_max < 1:
426 raise ConfigError("thresholds.realign_max_commits must be a positive integer", path)
427 drift_warn = thresholds_raw.get("drift_warn_only")
428 if not isinstance(drift_warn, bool):
429 raise ConfigError("thresholds.drift_warn_only must be a boolean", path)
430 thresholds = ThresholdsConfig(
431 realign_max_commits=realign_max,
432 drift_warn_only=drift_warn,
433 )
434
435 freeze_raw = _require_mapping(raw.get("freeze_contract"), "freeze_contract", path)
436 enabled = freeze_raw.get("enabled")
437 if not isinstance(enabled, bool):
438 raise ConfigError("freeze_contract.enabled must be a boolean", path)
439 reviewer = _parse_reviewer_config(freeze_raw.get("reviewer"), path)
440 escalation = freeze_raw.get("human_escalation")
441 if not isinstance(escalation, list) or not all(isinstance(x, str) for x in escalation):
442 raise ConfigError("freeze_contract.human_escalation must be a list of strings", path)
443 _validate_human_escalation(list(escalation), path)
444
445 checkpoints, honesty, modules, extensions, extension_warnings = _parse_k9_modules(
446 raw, path, human_escalation=list(escalation)
447 )
448 if honesty.require_agent_signature and regime == "git-only":
449 raise ConfigError(
450 "honesty.require_agent_signature is forbidden under git-only",
451 path,
452 exit_code=26,
453 )
454 governance_gates = _parse_governance_gates(raw.get("governance_gates"), path)
455 model_routing = _parse_model_routing(raw.get("model_routing"), path)
456 cost_awareness = _parse_cost_awareness(raw.get("cost_awareness"), path)
457 session_bookends = _parse_session_bookends(raw.get("session_bookends"), path)
458 hosted_dashboard = _parse_hosted_dashboard(raw.get("hosted_dashboard"), path)
459 close_ritual = _parse_close_ritual(raw.get("close_ritual"), path)
460 workspace = _parse_workspace(raw.get("workspace"), path)
461
462 return OverseerConfig(
463 overseer_config_version=version,
464 repo=repo,
465 vcs=VcsConfig(regime=regime, canonical=canonical, git=git, muse=muse),
466 docs=docs,
467 thresholds=thresholds,
468 freeze_contract=FreezeContractConfig(
469 enabled=enabled,
470 reviewer=reviewer,
471 human_escalation=list(escalation),
472 ),
473 checkpoints=checkpoints,
474 honesty=honesty,
475 modules=modules,
476 extensions=extensions,
477 extension_warnings=extension_warnings,
478 governance_gates=governance_gates,
479 model_routing=model_routing,
480 cost_awareness=cost_awareness,
481 session_bookends=session_bookends,
482 hosted_dashboard=hosted_dashboard,
483 close_ritual=close_ritual,
484 workspace=workspace,
485 )
486
487
488 def _validate_muse_working_dir_shape(working_dir: str | None, path: str) -> None:
489 """Fail closed on absolute / ``..`` working_dir values (§K6.5.1)."""
490 if working_dir is None:
491 return
492 text = working_dir.strip()
493 if not text:
494 raise ConfigError("vcs.muse.working_dir must be a non-empty string or null", path)
495 candidate = Path(text)
496 if candidate.is_absolute():
497 raise ConfigError("vcs.muse.working_dir must be relative to the install root", path)
498 if ".." in candidate.parts:
499 raise ConfigError("vcs.muse.working_dir must not contain '..' path segments", path)
500
501
502 def _validate_human_escalation(tokens: list[str], path: str) -> None:
503 for token in tokens:
504 if token not in HUMAN_ESCALATION_TOKENS:
505 raise ConfigError(
506 f"unknown freeze_contract.human_escalation token {token!r}",
507 path,
508 )
509
510
511 def _parse_reviewer_config(raw_reviewer: Any, path: str) -> ReviewerConfig:
512 """Parse nested reviewer mapping or legacy string (§K5.3)."""
513 if isinstance(raw_reviewer, str):
514 if raw_reviewer not in REVIEWER_MODES:
515 raise ConfigError(
516 f"freeze_contract.reviewer legacy string must be agent|human, got {raw_reviewer!r}",
517 path,
518 )
519 return ReviewerConfig(
520 mode=raw_reviewer,
521 model=DEFAULT_REVIEWER_MODEL,
522 provider=DEFAULT_REVIEWER_PROVIDER,
523 fallback=DEFAULT_REVIEWER_FALLBACK,
524 )
525
526 reviewer_raw = _require_mapping(raw_reviewer, "freeze_contract.reviewer", path)
527 extra = set(reviewer_raw) - REVIEWER_MAPPING_KEYS
528 if extra:
529 raise ConfigError(
530 f"unknown freeze_contract.reviewer keys: {sorted(extra)}",
531 path,
532 )
533
534 mode = _require_str(reviewer_raw, "mode", path)
535 if mode not in REVIEWER_MODES:
536 raise ConfigError(f"freeze_contract.reviewer.mode must be agent|human", path)
537
538 if mode == "agent":
539 # §K5.3: all required fields for agent mode must be present; missing → 2.
540 for field_name in ("model", "provider", "fallback"):
541 if field_name not in reviewer_raw:
542 raise ConfigError(
543 f"freeze_contract.reviewer.{field_name} is required when mode is agent",
544 path,
545 )
546 model = reviewer_raw["model"]
547 provider = reviewer_raw["provider"]
548 fallback = reviewer_raw["fallback"]
549 if not isinstance(model, str) or not model.strip():
550 raise ConfigError("freeze_contract.reviewer.model must be a non-empty string", path)
551 if provider not in REVIEWER_PROVIDERS:
552 raise ConfigError("freeze_contract.reviewer.provider must be local|api", path)
553 if fallback not in REVIEWER_FALLBACK:
554 raise ConfigError("freeze_contract.reviewer.fallback must be human", path)
555 else:
556 # Human mode: model/provider/fallback optional; unused at runtime (§K5.2 step 6).
557 model = reviewer_raw.get("model", DEFAULT_REVIEWER_MODEL)
558 provider = reviewer_raw.get("provider", DEFAULT_REVIEWER_PROVIDER)
559 fallback = reviewer_raw.get("fallback", DEFAULT_REVIEWER_FALLBACK)
560 if not isinstance(model, str) or not model.strip():
561 model = DEFAULT_REVIEWER_MODEL
562 if provider not in REVIEWER_PROVIDERS:
563 provider = DEFAULT_REVIEWER_PROVIDER
564 if fallback not in REVIEWER_FALLBACK:
565 fallback = DEFAULT_REVIEWER_FALLBACK
566
567 return ReviewerConfig(
568 mode=mode,
569 model=model,
570 provider=provider,
571 fallback=fallback,
572 )
573
574
575 def _parse_lanes(raw_lanes: Any, path: str) -> dict[str, LaneDocsConfig] | None:
576 """Parse optional ``docs.lanes`` mapping (§K8)."""
577 if raw_lanes is None:
578 return None
579 if not isinstance(raw_lanes, dict):
580 raise ConfigError("docs.lanes must be a mapping", path)
581 lanes: dict[str, LaneDocsConfig] = {}
582 for lane_name, lane_raw in raw_lanes.items():
583 if not isinstance(lane_name, str) or not lane_name.strip():
584 raise ConfigError("docs.lanes keys must be non-empty strings", path)
585 lane_map = _require_mapping(lane_raw, f"docs.lanes.{lane_name}", path)
586 lanes[lane_name.strip()] = LaneDocsConfig(
587 handover=_require_str(lane_map, "handover", path),
588 roadmap=_require_str(lane_map, "roadmap", path),
589 handover_title=_optional_str(lane_map, "handover_title") or "Overseer Handover",
590 roadmap_title=_optional_str(lane_map, "roadmap_title") or "Roadmap",
591 )
592 return lanes
593
594
595 def _validate_lanes_config(
596 *,
597 lanes: dict[str, LaneDocsConfig] | None,
598 default_lane: str | None,
599 handover: str,
600 roadmap: str,
601 handover_title: str,
602 roadmap_title: str,
603 path: str,
604 ) -> None:
605 """Fail closed on inconsistent multi-lane docs config (§K8)."""
606 if lanes is None:
607 if default_lane is not None:
608 raise ConfigError("docs.default_lane requires docs.lanes", path)
609 return
610 if not lanes:
611 raise ConfigError("docs.lanes must not be empty when present", path)
612 if not default_lane or not default_lane.strip():
613 raise ConfigError("docs.default_lane is required when docs.lanes is set", path)
614 default_lane = default_lane.strip()
615 if default_lane not in lanes:
616 raise ConfigError(
617 f"docs.default_lane {default_lane!r} is not a key in docs.lanes",
618 path,
619 )
620 default = lanes[default_lane]
621 if handover != default.handover or roadmap != default.roadmap:
622 raise ConfigError(
623 "docs.handover and docs.roadmap must match docs.lanes[default_lane]",
624 path,
625 )
626 if handover_title != default.handover_title or roadmap_title != default.roadmap_title:
627 raise ConfigError(
628 "docs.handover_title and docs.roadmap_title must match docs.lanes[default_lane]",
629 path,
630 )
631
632
633 def resolve_lane_docs(config: OverseerConfig, lane: str | None) -> LaneDocsConfig:
634 """Return the handover/roadmap pair for ``lane`` or the default lane (§K8)."""
635 docs = config.docs
636 if docs.lanes is None:
637 if lane is not None:
638 raise ConfigError("docs.lanes is not configured; --lane is not supported")
639 return LaneDocsConfig(
640 handover=docs.handover,
641 roadmap=docs.roadmap,
642 handover_title=docs.handover_title,
643 roadmap_title=docs.roadmap_title,
644 )
645 name = (lane or docs.default_lane or "").strip()
646 if name not in docs.lanes:
647 known = ", ".join(sorted(docs.lanes))
648 raise ConfigError(f"unknown lane {name!r} (configured: {known})")
649 return docs.lanes[name]
650
651
652 def list_configured_lanes(config: OverseerConfig) -> tuple[str, ...]:
653 """Return sorted lane names; single implicit lane when ``docs.lanes`` is absent."""
654 if config.docs.lanes is None:
655 return ("default",)
656 return tuple(sorted(config.docs.lanes))
657
658
659 def _validate_regime_fields(
660 regime: str,
661 canonical: str,
662 git: GitConfig,
663 muse: MuseConfig,
664 path: str,
665 ) -> None:
666 if regime == "git-only":
667 if canonical != "git":
668 raise ConfigError("git-only regime requires vcs.canonical: git", path)
669 if muse.staging_remote is not None or muse.main_branch is not None:
670 raise ConfigError("git-only regime requires muse fields to be null", path)
671 return
672
673 if regime == "muse-only":
674 if canonical != "muse":
675 raise ConfigError("muse-only regime requires vcs.canonical: muse", path)
676 if not muse.main_branch:
677 raise ConfigError("muse-only regime requires vcs.muse.main_branch", path)
678 return
679
680 if regime == "muse+git-mirror":
681 if canonical != "muse":
682 raise ConfigError("muse+git-mirror regime requires vcs.canonical: muse", path)
683 if not muse.main_branch:
684 raise ConfigError("muse+git-mirror regime requires vcs.muse.main_branch", path)
685 if not muse.staging_remote:
686 raise ConfigError("muse+git-mirror regime requires vcs.muse.staging_remote", path)
687 if not git.mirror_branch:
688 raise ConfigError("muse+git-mirror regime requires vcs.git.mirror_branch", path)
689
690
691 def _validate_repo_relative_path(value: str, field: str, path: str) -> None:
692 """Reject absolute paths and ``..`` segments in config path fields."""
693 text = value.strip()
694 if not text:
695 raise ConfigError(f"{field} must be a non-empty string", path)
696 candidate = Path(text)
697 if candidate.is_absolute():
698 raise ConfigError(f"{field} must be repo-relative", path)
699 if ".." in candidate.parts:
700 raise ConfigError(f"{field} must not contain '..' path segments", path)
701
702
703 def _parse_k9_modules(
704 raw: dict[str, Any],
705 path: str,
706 *,
707 human_escalation: list[str] | None = None,
708 ) -> tuple[CheckpointsConfig, HonestyConfig, ModulesConfig | None, tuple[ExtensionEntry, ...], tuple[str, ...]]:
709 """Parse optional K9 checkpoints/honesty/modules/extensions (§K9.2)."""
710 checkpoints = _parse_checkpoints(raw.get("checkpoints"), path)
711 honesty = _parse_honesty(
712 raw.get("honesty"), path, human_escalation=human_escalation or []
713 )
714 modules = _parse_modules(raw.get("modules"), path)
715 extensions, extension_warnings = _parse_extensions(raw.get("extensions"), path)
716
717 if modules is not None:
718 if modules.governance_enabled is False:
719 raise ConfigError("modules.governance.enabled cannot be false", path)
720 if modules.checkpoints_enabled is not None and modules.checkpoints_enabled != checkpoints.enabled:
721 raise ConfigError(
722 "modules.checkpoints.enabled must equal checkpoints.enabled",
723 path,
724 )
725 if modules.honesty_enabled is not None and modules.honesty_enabled != honesty.enabled:
726 raise ConfigError(
727 "modules.honesty.enabled must equal honesty.enabled",
728 path,
729 )
730
731 return checkpoints, honesty, modules, extensions, extension_warnings
732
733
734 def _parse_checkpoints(raw_checkpoints: Any, path: str) -> CheckpointsConfig:
735 if raw_checkpoints is None:
736 return CheckpointsConfig()
737 cp_raw = _require_mapping(raw_checkpoints, "checkpoints", path)
738 extra = set(cp_raw) - CHECKPOINTS_KEYS
739 if extra:
740 raise ConfigError(f"unknown checkpoints keys: {sorted(extra)}", path)
741
742 enabled = cp_raw.get("enabled", False)
743 if not isinstance(enabled, bool):
744 raise ConfigError("checkpoints.enabled must be a boolean", path)
745
746 policy = _optional_str(cp_raw, "policy")
747 if policy is not None:
748 _validate_repo_relative_path(policy, "checkpoints.policy", path)
749 active_manifest = _optional_str(cp_raw, "active_manifest")
750 if active_manifest is not None:
751 _validate_repo_relative_path(active_manifest, "checkpoints.active_manifest", path)
752 progress = _optional_str(cp_raw, "progress")
753 if progress is not None:
754 _validate_repo_relative_path(progress, "checkpoints.progress", path)
755 orchestrator = _optional_str(cp_raw, "orchestrator")
756 if orchestrator is not None:
757 _validate_repo_relative_path(orchestrator, "checkpoints.orchestrator", path)
758
759 allow_hand = cp_raw.get("allow_hand_verified", False)
760 if not isinstance(allow_hand, bool):
761 raise ConfigError("checkpoints.allow_hand_verified must be a boolean", path)
762 if allow_hand:
763 raise ConfigError("checkpoints.allow_hand_verified is forbidden", path)
764
765 if enabled and (policy is None or not policy.strip()):
766 raise ConfigError("checkpoints.enabled requires non-empty checkpoints.policy", path)
767
768 return CheckpointsConfig(
769 enabled=enabled,
770 policy=policy,
771 active_manifest=active_manifest,
772 progress=progress,
773 orchestrator=orchestrator,
774 allow_hand_verified=allow_hand,
775 )
776
777
778 def _parse_honesty(
779 raw_honesty: Any,
780 path: str,
781 *,
782 human_escalation: list[str] | None = None,
783 ) -> HonestyConfig:
784 escalation = list(human_escalation or [])
785 if raw_honesty is None:
786 derived = "suggest" if "security" in escalation else "off"
787 return HonestyConfig(adversarial_freeze=derived)
788 h_raw = _require_mapping(raw_honesty, "honesty", path)
789 extra = set(h_raw) - HONESTY_KEYS
790 if extra:
791 raise ConfigError(f"unknown honesty keys: {sorted(extra)}", path)
792
793 enabled = h_raw.get("enabled", False)
794 if not isinstance(enabled, bool):
795 raise ConfigError("honesty.enabled must be a boolean", path)
796
797 ledger = _optional_str(h_raw, "ledger")
798 if ledger is not None:
799 _validate_repo_relative_path(ledger, "honesty.ledger", path)
800 roles_file = _optional_str(h_raw, "roles_file")
801 if roles_file is not None:
802 _validate_repo_relative_path(roles_file, "honesty.roles_file", path)
803 ci_reexecutor = _optional_str(h_raw, "ci_reexecutor")
804 if ci_reexecutor is not None:
805 _validate_repo_relative_path(ci_reexecutor, "honesty.ci_reexecutor", path)
806
807 require_l1 = h_raw.get("require_l1_evidence", "warn")
808 if not isinstance(require_l1, str) or require_l1 not in L1_EVIDENCE_MODES:
809 raise ConfigError("honesty.require_l1_evidence must be off|warn|require", path)
810
811 require_verification = h_raw.get("require_verification_evidence", "off")
812 if not isinstance(require_verification, str) or require_verification not in L1_EVIDENCE_MODES:
813 raise ConfigError("honesty.require_verification_evidence must be off|warn|require", path)
814
815 require_deploy_health = h_raw.get("require_deploy_health", "off")
816 if not isinstance(require_deploy_health, str) or require_deploy_health not in L1_EVIDENCE_MODES:
817 raise ConfigError("honesty.require_deploy_health must be off|warn|require", path)
818
819 require_isr = h_raw.get("require_independent_second_reviewer", "require")
820 if not isinstance(require_isr, str) or require_isr not in L1_EVIDENCE_MODES:
821 raise ConfigError(
822 "honesty.require_independent_second_reviewer must be off|warn|require", path
823 )
824
825 if "adversarial_freeze" in h_raw:
826 adversarial_freeze = h_raw.get("adversarial_freeze")
827 if (
828 not isinstance(adversarial_freeze, str)
829 or adversarial_freeze not in ADVERSARIAL_FREEZE_MODES
830 ):
831 raise ConfigError(
832 "honesty.adversarial_freeze must be off|suggest|require", path
833 )
834 else:
835 adversarial_freeze = "suggest" if "security" in escalation else "off"
836
837 allow_signed = h_raw.get("allow_signed_approval", False)
838 if not isinstance(allow_signed, bool):
839 raise ConfigError("honesty.allow_signed_approval must be a boolean", path)
840
841 require_agent_signature = h_raw.get("require_agent_signature", False)
842 if not isinstance(require_agent_signature, bool):
843 raise ConfigError("honesty.require_agent_signature must be a boolean", path)
844
845 require_verdict_on = _parse_require_verdict_on(h_raw.get("require_verdict_on"), path)
846
847 if enabled and (ledger is None or not ledger.strip()):
848 raise ConfigError("honesty.enabled requires non-empty honesty.ledger", path)
849
850 return HonestyConfig(
851 enabled=enabled,
852 ledger=ledger,
853 roles_file=roles_file,
854 require_verdict_on=require_verdict_on,
855 require_l1_evidence=require_l1,
856 require_verification_evidence=require_verification,
857 require_deploy_health=require_deploy_health,
858 require_independent_second_reviewer=require_isr,
859 adversarial_freeze=adversarial_freeze,
860 allow_signed_approval=allow_signed,
861 ci_reexecutor=ci_reexecutor,
862 require_agent_signature=require_agent_signature,
863 )
864
865
866 def _parse_require_verdict_on(raw_value: Any, path: str) -> frozenset[str]:
867 if raw_value is None:
868 return frozenset(HOOK_NAMES)
869 if not isinstance(raw_value, list):
870 raise ConfigError("honesty.require_verdict_on must be a list or null", path)
871 if not raw_value:
872 raise ConfigError("honesty.require_verdict_on must not be empty", path)
873 hooks: set[str] = set()
874 for item in raw_value:
875 if not isinstance(item, str) or item not in HOOK_NAMES:
876 raise ConfigError(
877 "honesty.require_verdict_on entries must be board_done|handoff|register",
878 path,
879 )
880 hooks.add(item)
881 return frozenset(hooks)
882
883
884 def _parse_modules(raw_modules: Any, path: str) -> ModulesConfig | None:
885 if raw_modules is None:
886 return None
887 m_raw = _require_mapping(raw_modules, "modules", path)
888 allowed = {"governance", "checkpoints", "honesty"}
889 extra = set(m_raw) - allowed
890 if extra:
891 raise ConfigError(f"unknown modules keys: {sorted(extra)}", path)
892
893 governance_enabled = True
894 if "governance" in m_raw:
895 gov_raw = _require_mapping(m_raw["governance"], "modules.governance", path)
896 gov_extra = set(gov_raw) - MODULES_GOVERNANCE_KEYS
897 if gov_extra:
898 raise ConfigError(f"unknown modules.governance keys: {sorted(gov_extra)}", path)
899 gov_enabled = gov_raw.get("enabled", True)
900 if not isinstance(gov_enabled, bool):
901 raise ConfigError("modules.governance.enabled must be a boolean", path)
902 governance_enabled = gov_enabled
903
904 checkpoints_enabled: bool | None = None
905 if "checkpoints" in m_raw:
906 cp_raw = _require_mapping(m_raw["checkpoints"], "modules.checkpoints", path)
907 cp_extra = set(cp_raw) - MODULES_CHECKPOINTS_KEYS
908 if cp_extra:
909 raise ConfigError(f"unknown modules.checkpoints keys: {sorted(cp_extra)}", path)
910 value = cp_raw.get("enabled")
911 if not isinstance(value, bool):
912 raise ConfigError("modules.checkpoints.enabled must be a boolean", path)
913 checkpoints_enabled = value
914
915 honesty_enabled: bool | None = None
916 if "honesty" in m_raw:
917 h_raw = _require_mapping(m_raw["honesty"], "modules.honesty", path)
918 h_extra = set(h_raw) - MODULES_HONESTY_KEYS
919 if h_extra:
920 raise ConfigError(f"unknown modules.honesty keys: {sorted(h_extra)}", path)
921 value = h_raw.get("enabled")
922 if not isinstance(value, bool):
923 raise ConfigError("modules.honesty.enabled must be a boolean", path)
924 honesty_enabled = value
925
926 return ModulesConfig(
927 governance_enabled=governance_enabled,
928 checkpoints_enabled=checkpoints_enabled,
929 honesty_enabled=honesty_enabled,
930 )
931
932
933 def _parse_post_land_sync(raw_sync: Any, path: str) -> PostLandSyncConfig:
934 """Parse optional ``close_ritual.post_land_sync`` (§PLS.3.2 — fail-closed)."""
935 if raw_sync is None:
936 return PostLandSyncConfig()
937 sync_raw = _require_mapping(raw_sync, "close_ritual.post_land_sync", path)
938 allowed = {"enabled", "strategy", "require_clean_worktree"}
939 extra = set(sync_raw) - allowed
940 if extra:
941 raise ConfigError(f"unknown close_ritual.post_land_sync keys: {sorted(extra)}", path)
942
943 enabled = sync_raw.get("enabled", False)
944 if not isinstance(enabled, bool):
945 raise ConfigError("close_ritual.post_land_sync.enabled must be a boolean", path)
946
947 strategy = sync_raw.get("strategy", "ff_only")
948 if strategy != "ff_only":
949 raise ConfigError(
950 "close_ritual.post_land_sync.strategy must be ff_only (v1 closed vocabulary)",
951 path,
952 )
953
954 require_clean = sync_raw.get("require_clean_worktree", True)
955 if not isinstance(require_clean, bool) or require_clean is not True:
956 raise ConfigError(
957 "close_ritual.post_land_sync.require_clean_worktree must be true in v1 "
958 "(dirty-tree merges are a non-goal)",
959 path,
960 )
961
962 return PostLandSyncConfig(
963 enabled=enabled,
964 strategy="ff_only",
965 require_clean_worktree=True,
966 )
967
968
969 def _parse_close_ritual(raw_ritual: Any, path: str) -> CloseRitualConfig:
970 """Parse optional ``close_ritual`` section (land-to-main; never auto-merge)."""
971 if raw_ritual is None:
972 return CloseRitualConfig()
973 ritual_raw = _require_mapping(raw_ritual, "close_ritual", path)
974 allowed = {"enabled", "mode", "require_paths", "consumer_verify_script", "post_land_sync"}
975 extra = set(ritual_raw) - allowed
976 if extra:
977 raise ConfigError(f"unknown close_ritual keys: {sorted(extra)}", path)
978
979 enabled = ritual_raw.get("enabled", False)
980 if not isinstance(enabled, bool):
981 raise ConfigError("close_ritual.enabled must be a boolean", path)
982
983 mode = ritual_raw.get("mode", "verify_landed")
984 if mode not in {"verify_landed", "prepare_pr"}:
985 raise ConfigError(
986 "close_ritual.mode must be verify_landed|prepare_pr",
987 path,
988 )
989
990 paths_raw = ritual_raw.get("require_paths", [])
991 if not isinstance(paths_raw, list) or not all(isinstance(p, str) and p.strip() for p in paths_raw):
992 raise ConfigError("close_ritual.require_paths must be a list of non-empty strings", path)
993 for p in paths_raw:
994 candidate = Path(p)
995 if candidate.is_absolute() or ".." in candidate.parts:
996 raise ConfigError(
997 "close_ritual.require_paths entries must be relative without '..'",
998 path,
999 )
1000
1001 script = _optional_str(ritual_raw, "consumer_verify_script")
1002 if script is not None:
1003 script_path = Path(script)
1004 if script_path.is_absolute() or ".." in script_path.parts:
1005 raise ConfigError(
1006 "close_ritual.consumer_verify_script must be relative without '..'",
1007 path,
1008 )
1009
1010 return CloseRitualConfig(
1011 enabled=enabled,
1012 mode=str(mode),
1013 require_paths=tuple(str(p).strip() for p in paths_raw),
1014 consumer_verify_script=script,
1015 post_land_sync=_parse_post_land_sync(ritual_raw.get("post_land_sync"), path),
1016 )
1017
1018
1019 def _parse_governance_gates(raw_gates: Any, path: str) -> GovernanceGatesConfig:
1020 """Parse optional ``governance_gates`` section (§KH1.9)."""
1021 if raw_gates is None:
1022 return GovernanceGatesConfig()
1023 gates_raw = _require_mapping(raw_gates, "governance_gates", path)
1024 extra = set(gates_raw) - GOVERNANCE_GATES_KEYS
1025 if extra:
1026 raise ConfigError(f"unknown governance_gates keys: {sorted(extra)}", path)
1027
1028 remind = gates_raw.get("remind", True)
1029 if not isinstance(remind, bool):
1030 raise ConfigError("governance_gates.remind must be a boolean", path)
1031
1032 freeze_required = True
1033 if "freeze_review" in gates_raw:
1034 freeze_raw = _require_mapping(gates_raw["freeze_review"], "governance_gates.freeze_review", path)
1035 freeze_extra = set(freeze_raw) - {"required_before_auto"}
1036 if freeze_extra:
1037 raise ConfigError(
1038 f"unknown governance_gates.freeze_review keys: {sorted(freeze_extra)}",
1039 path,
1040 )
1041 value = freeze_raw.get("required_before_auto", True)
1042 if not isinstance(value, bool):
1043 raise ConfigError(
1044 "governance_gates.freeze_review.required_before_auto must be a boolean",
1045 path,
1046 )
1047 freeze_required = value
1048
1049 build_required = True
1050 if "build_verification" in gates_raw:
1051 build_raw = _require_mapping(
1052 gates_raw["build_verification"],
1053 "governance_gates.build_verification",
1054 path,
1055 )
1056 build_extra = set(build_raw) - {"required_before_done"}
1057 if build_extra:
1058 raise ConfigError(
1059 f"unknown governance_gates.build_verification keys: {sorted(build_extra)}",
1060 path,
1061 )
1062 value = build_raw.get("required_before_done", True)
1063 if not isinstance(value, bool):
1064 raise ConfigError(
1065 "governance_gates.build_verification.required_before_done must be a boolean",
1066 path,
1067 )
1068 build_required = value
1069
1070 surfaces: frozenset[str] = frozenset(GOVERNANCE_GATES_SURFACES)
1071 if "surfaces" in gates_raw:
1072 raw_surfaces = gates_raw["surfaces"]
1073 if not isinstance(raw_surfaces, list) or not raw_surfaces:
1074 raise ConfigError("governance_gates.surfaces must be a non-empty list", path)
1075 parsed: set[str] = set()
1076 for item in raw_surfaces:
1077 if not isinstance(item, str) or item not in GOVERNANCE_GATES_SURFACES:
1078 raise ConfigError(
1079 "governance_gates.surfaces entries must be status|governance-sync|handover-paste",
1080 path,
1081 )
1082 parsed.add(item)
1083 surfaces = frozenset(parsed)
1084
1085 return GovernanceGatesConfig(
1086 remind=remind,
1087 freeze_review_required=freeze_required,
1088 build_verification_required=build_required,
1089 surfaces=surfaces,
1090 )
1091
1092
1093 def _parse_model_routing(raw_routing: Any, path: str) -> ModelRoutingConfig:
1094 """Parse optional ``model_routing`` section (§PR.5)."""
1095 if raw_routing is None:
1096 return ModelRoutingConfig()
1097 routing_raw = _require_mapping(raw_routing, "model_routing", path)
1098 extra = set(routing_raw) - MODEL_ROUTING_KEYS
1099 if extra:
1100 raise ConfigError(f"unknown model_routing keys: {sorted(extra)}", path)
1101
1102 enabled = routing_raw.get("enabled", False)
1103 if not isinstance(enabled, bool):
1104 raise ConfigError("model_routing.enabled must be a boolean", path)
1105
1106 policy = routing_raw.get("policy", DEFAULT_MODEL_ROUTING_POLICY)
1107 if not isinstance(policy, str) or not policy.strip():
1108 raise ConfigError("model_routing.policy must be a non-empty string", path)
1109 _validate_repo_relative_path(policy, "model_routing.policy", path)
1110
1111 return ModelRoutingConfig(enabled=enabled, policy=policy.strip())
1112
1113
1114 def _parse_hosted_dashboard(raw_block: Any, path: str) -> HostedDashboardSection:
1115 """Parse optional ``hosted_dashboard`` section (§HGD.10.2); unknown keys fail closed."""
1116 if raw_block is None:
1117 return HostedDashboardSection()
1118 try:
1119 from tools.hosted_dashboard.config import parse_hosted_dashboard_config
1120
1121 parsed = parse_hosted_dashboard_config(raw_block, path=path)
1122 except Exception as exc:
1123 raise ConfigError(str(exc), path) from exc
1124 return HostedDashboardSection(
1125 enabled=parsed.enabled,
1126 allow_non_loopback=parsed.allow_non_loopback,
1127 cors_origins=parsed.cors_origins,
1128 org_allowlist=parsed.org_allowlist,
1129 github_contents=parsed.sources.github_contents,
1130 github_meta=parsed.sources.github_meta,
1131 github_checks_advisory=parsed.sources.github_checks_advisory,
1132 musehub_read=parsed.sources.musehub_read,
1133 )
1134
1135
1136 def _parse_cost_awareness(raw_cost: Any, path: str) -> CostAwarenessConfig:
1137 """Parse optional ``cost_awareness`` section (§PC.6)."""
1138 if raw_cost is None:
1139 return CostAwarenessConfig()
1140 cost_raw = _require_mapping(raw_cost, "cost_awareness", path)
1141 extra = set(cost_raw) - COST_AWARENESS_KEYS
1142 if extra:
1143 raise ConfigError(f"unknown cost_awareness keys: {sorted(extra)}", path)
1144
1145 enabled = cost_raw.get("enabled", False)
1146 if not isinstance(enabled, bool):
1147 raise ConfigError("cost_awareness.enabled must be a boolean", path)
1148
1149 surfaces: frozenset[str] = frozenset(COST_AWARENESS_SURFACES)
1150 if "surfaces" in cost_raw:
1151 raw_surfaces = cost_raw["surfaces"]
1152 if not isinstance(raw_surfaces, list) or not raw_surfaces:
1153 raise ConfigError("cost_awareness.surfaces must be a non-empty list", path)
1154 parsed: set[str] = set()
1155 for item in raw_surfaces:
1156 if not isinstance(item, str) or item not in COST_AWARENESS_SURFACES:
1157 raise ConfigError(
1158 "cost_awareness.surfaces entries must be status|governance-sync",
1159 path,
1160 )
1161 parsed.add(item)
1162 surfaces = frozenset(parsed)
1163
1164 return CostAwarenessConfig(enabled=enabled, surfaces=surfaces)
1165
1166
1167 def _parse_session_bookends(raw_bookends: Any, path: str) -> SessionBookendsConfig:
1168 """Parse optional ``session_bookends`` section (§LT.4.2)."""
1169 if raw_bookends is None:
1170 return SessionBookendsConfig()
1171 bookends_raw = _require_mapping(raw_bookends, "session_bookends", path)
1172 extra = set(bookends_raw) - SESSION_BOOKENDS_KEYS
1173 if extra:
1174 raise ConfigError(f"unknown session_bookends keys: {sorted(extra)}", path)
1175
1176 enabled = bookends_raw.get("enabled", False)
1177 if not isinstance(enabled, bool):
1178 raise ConfigError("session_bookends.enabled must be a boolean", path)
1179
1180 return SessionBookendsConfig(enabled=enabled)
1181
1182
1183 def _parse_workspace(raw_workspace: Any, path: str) -> WorkspacePointerConfig | None:
1184 """Parse optional additive ``workspace:`` pointer (§MR.4.2)."""
1185 if raw_workspace is None:
1186 return None
1187 ws_raw = _require_mapping(raw_workspace, "workspace", path)
1188 extra = set(ws_raw) - WORKSPACE_KEYS
1189 if extra:
1190 raise ConfigError(f"unknown workspace keys: {sorted(extra)}", path)
1191 constellation_id = _require_str(ws_raw, "constellation_id", path)
1192 product_order_root = _optional_str(ws_raw, "product_order_root")
1193 manifest = _optional_str(ws_raw, "manifest")
1194 return WorkspacePointerConfig(
1195 constellation_id=constellation_id,
1196 product_order_root=product_order_root,
1197 manifest=manifest,
1198 )
1199
1200
1201 def _parse_extensions(
1202 raw_extensions: Any,
1203 path: str,
1204 ) -> tuple[tuple[ExtensionEntry, ...], tuple[str, ...]]:
1205 if raw_extensions is None:
1206 return (), ()
1207 if not isinstance(raw_extensions, list):
1208 raise ConfigError("extensions must be a list", path)
1209
1210 entries: list[ExtensionEntry] = []
1211 warnings: list[str] = []
1212 for index, item in enumerate(raw_extensions):
1213 prefix = f"extensions[{index}]"
1214 if not isinstance(item, dict):
1215 raise ConfigError(f"{prefix} must be a mapping", path)
1216 extra = set(item) - EXTENSION_KEYS
1217 if extra:
1218 raise ConfigError(f"unknown {prefix} keys: {sorted(extra)}", path)
1219 ext_id = item.get("id")
1220 if not isinstance(ext_id, str) or not ext_id.strip():
1221 raise ConfigError(f"{prefix}.id must be a non-empty string", path)
1222 schema_version = item.get("schema_version")
1223 if not isinstance(schema_version, int):
1224 raise ConfigError(f"{prefix}.schema_version must be an integer", path)
1225 config_path = item.get("config_path")
1226 if not isinstance(config_path, str) or not config_path.strip():
1227 raise ConfigError(f"{prefix}.config_path must be a non-empty string", path)
1228 _validate_repo_relative_path(config_path, f"{prefix}.config_path", path)
1229 entries.append(
1230 ExtensionEntry(id=ext_id.strip(), schema_version=schema_version, config_path=config_path)
1231 )
1232 warnings.append(
1233 f"extensions[{index}] id={ext_id!r} schema_version={schema_version} ignored (v1 registry empty)"
1234 )
1235 return tuple(entries), tuple(warnings)
File History 1 commit
sha256:8461d44b77376fbf06fa7c3e085d309e3010fd8d5886d63c63e69ce118811ad4 docs: record AFF-b feature-tip SHAs after AFF-b-ISR commit. Human 4 days ago