openmapstack 0.2.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.
openmapstack/rerun.py ADDED
@@ -0,0 +1,332 @@
1
+ """Clean-rerun protocol for an ``openmapstack-project/v1`` artifact.
2
+
3
+ Rebuild a project in an empty workspace from only its manifest, its declared
4
+ immutable inputs, and its declared dependencies -- then execute the one
5
+ canonical entrypoint and revalidate what it produced.
6
+
7
+ This is the strongest correctness signal available on data nobody has a known
8
+ answer for. It needs no oracle: a pipeline that cannot reproduce itself from
9
+ source plus manifest is untrustworthy whatever its numbers say, and one that
10
+ mutates its own declared-immutable inputs is not reproducible at all.
11
+
12
+ Two callers share this: ``evals/run.py`` (case ``clean_rerun: {}``) and
13
+ ``openmapstack verify --rerun``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import shlex
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ from pathlib import Path
27
+ from typing import Any, Sequence
28
+
29
+ import yaml
30
+
31
+ from .validation import validate_project
32
+
33
+ CLEAN_RERUN_EVIDENCE = ".openmapstack-clean-rerun.json"
34
+
35
+
36
+ def _output_text(value: str | bytes | None) -> str:
37
+ if value is None:
38
+ return ""
39
+ if isinstance(value, bytes):
40
+ return value.decode("utf-8", errors="replace")
41
+ return value
42
+
43
+
44
+ def _execute_argv(command: list[str], cwd: Path, timeout_s: int | float, env: dict[str, str]) -> dict[str, Any]:
45
+ """Execute a shell-free canonical project entrypoint."""
46
+ started = time.monotonic()
47
+ try:
48
+ proc = subprocess.run(
49
+ command,
50
+ shell=False,
51
+ cwd=cwd,
52
+ capture_output=True,
53
+ text=True,
54
+ timeout=timeout_s,
55
+ env=env,
56
+ )
57
+ return {
58
+ "command": command,
59
+ "cwd": str(cwd),
60
+ "returncode": proc.returncode,
61
+ "timed_out": False,
62
+ "duration_s": time.monotonic() - started,
63
+ "stdout": proc.stdout,
64
+ "stderr": proc.stderr,
65
+ }
66
+ except subprocess.TimeoutExpired as exc:
67
+ return {
68
+ "command": command,
69
+ "cwd": str(cwd),
70
+ "returncode": None,
71
+ "timed_out": True,
72
+ "timeout_s": timeout_s,
73
+ "duration_s": time.monotonic() - started,
74
+ "stdout": _output_text(exc.stdout),
75
+ "stderr": _output_text(exc.stderr),
76
+ }
77
+ except OSError as exc:
78
+ return {
79
+ "command": command,
80
+ "cwd": str(cwd),
81
+ "returncode": None,
82
+ "timed_out": False,
83
+ "duration_s": time.monotonic() - started,
84
+ "stdout": "",
85
+ "stderr": f"{type(exc).__name__}: {exc}",
86
+ }
87
+
88
+
89
+ def _safe_project_path(project_root: Path, value: Any, field_name: str) -> tuple[Path, Path]:
90
+ if not isinstance(value, str) or not value.strip():
91
+ raise ValueError(f"{field_name} must be a non-empty project-relative path")
92
+ relative = Path(value)
93
+ if relative.is_absolute() or ".." in relative.parts:
94
+ raise ValueError(f"{field_name} escapes the project: {value!r}")
95
+ target = (project_root / relative).resolve()
96
+ try:
97
+ normalized = target.relative_to(project_root.resolve())
98
+ except ValueError as exc:
99
+ raise ValueError(f"{field_name} escapes the project: {value!r}") from exc
100
+ return target, normalized
101
+
102
+
103
+ def _hash_immutable_inputs(rerun_root: Path, preserved: set[str]) -> dict[str, str]:
104
+ """Real sha256 of every immutable source/override file actually on disk.
105
+
106
+ Only ``data/source/`` and ``data/overrides/`` are covered: these are the
107
+ only paths the spec declares immutable. The canonical entrypoint is
108
+ expected to write/replace files elsewhere (derived outputs, reports,
109
+ run records); it must never touch these two trees.
110
+ """
111
+ hashes: dict[str, str] = {}
112
+ for relative in sorted(preserved):
113
+ if not (relative == "data/source" or relative == "data/overrides" or relative.startswith("data/source/") or relative.startswith("data/overrides/")):
114
+ continue
115
+ target = rerun_root / relative
116
+ if target.is_dir():
117
+ for file_path in sorted(target.rglob("*")):
118
+ if file_path.is_file():
119
+ digest = hashlib.sha256(file_path.read_bytes()).hexdigest()
120
+ hashes[str(file_path.relative_to(rerun_root).as_posix())] = f"sha256:{digest}"
121
+ elif target.is_file():
122
+ digest = hashlib.sha256(target.read_bytes()).hexdigest()
123
+ hashes[relative] = f"sha256:{digest}"
124
+ return hashes
125
+
126
+ def _copy_clean_rerun_path(
127
+ project_root: Path,
128
+ rerun_root: Path,
129
+ value: str,
130
+ field_name: str,
131
+ preserved: set[str],
132
+ ) -> None:
133
+ source, relative = _safe_project_path(project_root, value, field_name)
134
+ relative_text = relative.as_posix()
135
+ if relative_text in preserved:
136
+ return
137
+ if not source.exists():
138
+ raise ValueError(f"declared clean-rerun dependency does not exist: {value}")
139
+ paths = [source]
140
+ if source.is_dir():
141
+ paths.extend(source.rglob("*"))
142
+ if any(path.is_symlink() for path in paths):
143
+ raise ValueError(f"clean-rerun dependency may not contain symlinks: {value}")
144
+
145
+ destination = rerun_root / relative
146
+ if source.is_dir():
147
+ shutil.copytree(source, destination, dirs_exist_ok=True)
148
+ else:
149
+ destination.parent.mkdir(parents=True, exist_ok=True)
150
+ shutil.copy2(source, destination)
151
+ preserved.add(relative_text)
152
+
153
+ def canonical_rerun_command(
154
+ project_root: Path,
155
+ project: dict[str, Any],
156
+ *,
157
+ forbidden_fragments: Sequence[str] = (),
158
+ ) -> tuple[list[str], list[tuple[str, str]]]:
159
+ """Resolve the one canonical entrypoint plus the paths a rerun must keep.
160
+
161
+ ``forbidden_fragments`` lets a caller reject commands that reach back into
162
+ machinery a clean rerun must not depend on. The eval runner passes its own
163
+ generator paths; nothing in the package knows about ``evals/``.
164
+ """
165
+ runtime = project.get("runtime")
166
+ implementation = runtime.get("implementation") if isinstance(runtime, dict) else None
167
+ if not isinstance(implementation, dict):
168
+ raise ValueError("runtime.implementation is missing")
169
+
170
+ preserve: list[tuple[str, str]] = []
171
+ dependencies = implementation.get("dependencies") or []
172
+ if not isinstance(dependencies, list) or not all(isinstance(item, str) and item.strip() for item in dependencies):
173
+ raise ValueError("runtime.implementation.dependencies must be a list of paths")
174
+ preserve.extend((dependency, f"runtime.implementation.dependencies[{index}]") for index, dependency in enumerate(dependencies))
175
+
176
+ declared_command = implementation.get("command")
177
+ if declared_command is not None:
178
+ if isinstance(declared_command, str):
179
+ command = shlex.split(declared_command)
180
+ elif isinstance(declared_command, list) and all(isinstance(item, str) and item for item in declared_command):
181
+ command = list(declared_command)
182
+ else:
183
+ raise ValueError("runtime.implementation.command must be a string or list of strings")
184
+ if not command:
185
+ raise ValueError("runtime.implementation.command is empty")
186
+
187
+ for index, token in enumerate(command):
188
+ for fragment in forbidden_fragments:
189
+ if fragment and fragment in token:
190
+ raise ValueError(
191
+ f"canonical command depends on excluded machinery: {fragment!r}"
192
+ )
193
+ token_path = Path(token)
194
+ if index > 0 and token_path.is_absolute():
195
+ raise ValueError(f"canonical command argument must not use an absolute path: {token!r}")
196
+ if ".." in token_path.parts:
197
+ raise ValueError(f"canonical command must not escape the project: {token!r}")
198
+ if not token.startswith("-") and not token_path.is_absolute():
199
+ candidate = project_root / token_path
200
+ if candidate.exists():
201
+ preserve.append((token, f"runtime.implementation.command[{index}]"))
202
+ return command, preserve
203
+
204
+ pipeline = implementation.get("pipeline")
205
+ pipeline_path, relative = _safe_project_path(project_root, pipeline, "runtime.implementation.pipeline")
206
+ if not pipeline_path.is_file():
207
+ raise ValueError(f"canonical pipeline does not exist: {pipeline!r}")
208
+ preserve.append((relative.as_posix(), "runtime.implementation.pipeline"))
209
+ if pipeline_path.suffix.lower() == ".py":
210
+ return [sys.executable, relative.as_posix()], preserve
211
+ if pipeline_path.stat().st_mode & 0o111:
212
+ executable = relative.as_posix()
213
+ return [executable if executable.startswith("./") else f"./{executable}"], preserve
214
+ raise ValueError("non-Python canonical pipeline is not executable and declares no command")
215
+
216
+ def _clean_rerun_environment() -> tuple[dict[str, str], list[str]]:
217
+ env = dict(os.environ)
218
+ sensitive_fragments = (
219
+ "ANTHROPIC",
220
+ "CHAT",
221
+ "CLAUDE",
222
+ "CODEX",
223
+ "CONVERSATION",
224
+ "OPENAI",
225
+ "PROMPT",
226
+ "TRANSCRIPT",
227
+ )
228
+ removed = sorted(key for key in env if any(fragment in key.upper() for fragment in sensitive_fragments))
229
+ for key in removed:
230
+ env.pop(key, None)
231
+ env.pop("PYTHONPATH", None)
232
+ env["OPENMAPSTACK_CLEAN_RERUN"] = "1"
233
+ return env, removed
234
+
235
+ def _write_clean_rerun_evidence(rerun_root: Path, evidence: dict[str, Any]) -> None:
236
+ (rerun_root / CLEAN_RERUN_EVIDENCE).write_text(json.dumps(evidence, indent=2, default=str), encoding="utf-8")
237
+
238
+ def perform_clean_rerun(
239
+ project_root: Path,
240
+ rerun_root: Path,
241
+ timeout_s: int | float,
242
+ *,
243
+ forbidden_fragments: Sequence[str] = (),
244
+ ) -> dict[str, Any]:
245
+ """Rebuild a project from its manifest, local immutable inputs, and declared dependencies."""
246
+ evidence: dict[str, Any] = {
247
+ "schema": "openmapstack-clean-rerun/v1",
248
+ "status": "failed",
249
+ "stage": "preparation",
250
+ "preserved_paths": [],
251
+ "excluded_artifact_classes": [
252
+ "derived_outputs",
253
+ "validation_reports",
254
+ "run_records",
255
+ "caches",
256
+ "presentation_artifacts",
257
+ "conversation_state",
258
+ ],
259
+ }
260
+ preserved: set[str] = set()
261
+ try:
262
+ manifest_path = project_root / "project.yaml"
263
+ if not manifest_path.is_file():
264
+ raise ValueError("project.yaml is missing")
265
+ try:
266
+ project = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
267
+ except (OSError, UnicodeError, yaml.YAMLError) as exc:
268
+ raise ValueError(f"project.yaml cannot be loaded: {exc}") from exc
269
+ if not isinstance(project, dict):
270
+ raise ValueError("project.yaml must contain a mapping")
271
+
272
+ command, declared_paths = canonical_rerun_command(
273
+ project_root, project, forbidden_fragments=forbidden_fragments
274
+ )
275
+ _copy_clean_rerun_path(project_root, rerun_root, "project.yaml", "project manifest", preserved)
276
+ for conventional_path in ("data/source", "data/overrides"):
277
+ if (project_root / conventional_path).exists():
278
+ _copy_clean_rerun_path(
279
+ project_root,
280
+ rerun_root,
281
+ conventional_path,
282
+ f"clean-rerun input {conventional_path}",
283
+ preserved,
284
+ )
285
+ for path, field_name in declared_paths:
286
+ _copy_clean_rerun_path(project_root, rerun_root, path, field_name, preserved)
287
+
288
+ evidence["preserved_paths"] = sorted(preserved)
289
+ source_hashes_before = _hash_immutable_inputs(rerun_root, preserved)
290
+ evidence["command"] = command
291
+ env, removed_environment = _clean_rerun_environment()
292
+ evidence["removed_environment_keys"] = removed_environment
293
+ execution = _execute_argv(command, rerun_root, timeout_s, env)
294
+ evidence["execution"] = execution
295
+ if execution.get("timed_out"):
296
+ evidence["stage"] = "canonical_execution"
297
+ evidence["error"] = f"canonical entrypoint timed out after {timeout_s}s"
298
+ _write_clean_rerun_evidence(rerun_root, evidence)
299
+ return evidence
300
+ if execution.get("returncode") != 0:
301
+ evidence["stage"] = "canonical_execution"
302
+ evidence["error"] = f"canonical entrypoint exited with status {execution.get('returncode')}"
303
+ _write_clean_rerun_evidence(rerun_root, evidence)
304
+ return evidence
305
+
306
+ evidence["stage"] = "source_integrity"
307
+ source_hashes_after = _hash_immutable_inputs(rerun_root, preserved)
308
+ mutated = sorted(relative for relative, digest in source_hashes_before.items() if source_hashes_after.get(relative) != digest)
309
+ evidence["source_hashes"] = source_hashes_after
310
+ if mutated:
311
+ evidence["error"] = f"canonical entrypoint mutated declared-immutable source/override files: {mutated}"
312
+ evidence["mutated_source_files"] = mutated
313
+ _write_clean_rerun_evidence(rerun_root, evidence)
314
+ return evidence
315
+
316
+ evidence["stage"] = "artifact_validation"
317
+ validation = validate_project(rerun_root / "project.yaml", artifacts=True)
318
+ evidence["artifact_validation"] = validation.to_dict()
319
+ if not validation.ok():
320
+ evidence["error"] = "post-rerun artifact validation failed"
321
+ _write_clean_rerun_evidence(rerun_root, evidence)
322
+ return evidence
323
+
324
+ evidence["status"] = "passed"
325
+ evidence["stage"] = "complete"
326
+ _write_clean_rerun_evidence(rerun_root, evidence)
327
+ return evidence
328
+ except (OSError, ValueError) as exc:
329
+ evidence["preserved_paths"] = sorted(preserved)
330
+ evidence["error"] = f"{type(exc).__name__}: {exc}"
331
+ _write_clean_rerun_evidence(rerun_root, evidence)
332
+ return evidence
openmapstack/schema.py ADDED
@@ -0,0 +1,39 @@
1
+ """JSON Schema loading and validation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import date, datetime
7
+ from importlib.resources import files
8
+ from typing import Any
9
+
10
+ from jsonschema import Draft202012Validator
11
+
12
+
13
+ def _json_value(value: Any) -> Any:
14
+ if isinstance(value, (date, datetime)):
15
+ return value.isoformat()
16
+ if isinstance(value, dict):
17
+ return {key: _json_value(item) for key, item in value.items()}
18
+ if isinstance(value, list):
19
+ return [_json_value(item) for item in value]
20
+ return value
21
+
22
+
23
+ def load_packaged_schema(name: str) -> dict[str, Any]:
24
+ resource = files("openmapstack.schemas").joinpath(name)
25
+ return json.loads(resource.read_text(encoding="utf-8"))
26
+
27
+
28
+ def validation_errors(instance: Any, schema: dict[str, Any]) -> list[str]:
29
+ validator = Draft202012Validator(schema)
30
+ errors = sorted(validator.iter_errors(_json_value(instance)), key=lambda item: list(item.path))
31
+ formatted: list[str] = []
32
+ for error in errors:
33
+ location = ".".join(str(part) for part in error.absolute_path) or "$"
34
+ formatted.append(f"{location}: {error.message}")
35
+ return formatted
36
+
37
+
38
+ def project_schema_errors(project: Any) -> list[str]:
39
+ return validation_errors(project, load_packaged_schema("project-v1.schema.json"))
@@ -0,0 +1 @@
1
+ """Packaged schemas for OpenMapStack contracts."""
@@ -0,0 +1,264 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openmapstack/schemas/project-v1.schema.json",
4
+ "title": "OpenMapStack project manifest v1",
5
+ "type": "object",
6
+ "required": [
7
+ "schema", "project", "interpretation", "sources", "overrides",
8
+ "processing", "outputs", "validation", "presentation", "runtime", "runs"
9
+ ],
10
+ "$defs": {
11
+ "digest": {
12
+ "type": "string",
13
+ "pattern": "^(sha256:)?[0-9a-fA-F]{64}$"
14
+ },
15
+ "fieldIdentifier": {
16
+ "type": "string",
17
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
18
+ },
19
+ "safeProjectPath": {
20
+ "type": "string",
21
+ "minLength": 1,
22
+ "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+"
23
+ },
24
+ "jsonScalar": {
25
+ "type": ["string", "number", "boolean", "null"]
26
+ },
27
+ "expectationAttestation": {
28
+ "type": "object",
29
+ "required": ["status"],
30
+ "additionalProperties": false,
31
+ "properties": {
32
+ "status": {"enum": ["unverified", "verified"]},
33
+ "reason": {"type": "string", "minLength": 1},
34
+ "verified_by": {"type": "string", "minLength": 1},
35
+ "verified_against": {"type": "string", "minLength": 1},
36
+ "verified_at": {"type": "string", "format": "date-time"},
37
+ "evidence_path": {"$ref": "#/$defs/safeProjectPath"},
38
+ "evidence_sha256": {"$ref": "#/$defs/digest"},
39
+ "expectation_sha256": {"$ref": "#/$defs/digest"},
40
+ "inputs_hash": {"$ref": "#/$defs/digest"}
41
+ },
42
+ "allOf": [
43
+ {
44
+ "if": {"properties": {"status": {"const": "verified"}}},
45
+ "then": {
46
+ "required": [
47
+ "verified_by", "verified_against", "verified_at",
48
+ "evidence_sha256", "expectation_sha256", "inputs_hash"
49
+ ]
50
+ }
51
+ }
52
+ ]
53
+ },
54
+ "expectation": {
55
+ "type": "object",
56
+ "required": ["id", "check", "args", "attestation"],
57
+ "additionalProperties": false,
58
+ "properties": {
59
+ "id": {
60
+ "type": "string",
61
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
62
+ },
63
+ "check": {
64
+ "enum": [
65
+ "geodata.row_count",
66
+ "geodata.feature_present",
67
+ "geodata.feature_absent",
68
+ "geodata.feature_field_equals",
69
+ "geodata.field_range"
70
+ ]
71
+ },
72
+ "args": {"type": "object"},
73
+ "attestation": {"$ref": "#/$defs/expectationAttestation"}
74
+ },
75
+ "allOf": [
76
+ {
77
+ "if": {"properties": {"check": {"const": "geodata.row_count"}}},
78
+ "then": {
79
+ "properties": {
80
+ "args": {
81
+ "type": "object",
82
+ "required": ["path"],
83
+ "additionalProperties": false,
84
+ "properties": {
85
+ "path": {"$ref": "#/$defs/safeProjectPath"},
86
+ "equals": {"type": "integer", "minimum": 0},
87
+ "at_least": {"type": "integer", "minimum": 0},
88
+ "at_most": {"type": "integer", "minimum": 0}
89
+ },
90
+ "anyOf": [
91
+ {"required": ["equals"]},
92
+ {"required": ["at_least"]},
93
+ {"required": ["at_most"]}
94
+ ]
95
+ }
96
+ }
97
+ }
98
+ },
99
+ {
100
+ "if": {
101
+ "properties": {
102
+ "check": {"enum": ["geodata.feature_present", "geodata.feature_absent"]}
103
+ }
104
+ },
105
+ "then": {
106
+ "properties": {
107
+ "args": {
108
+ "type": "object",
109
+ "required": ["path", "id_field", "id"],
110
+ "additionalProperties": false,
111
+ "properties": {
112
+ "path": {"$ref": "#/$defs/safeProjectPath"},
113
+ "id_field": {"$ref": "#/$defs/fieldIdentifier"},
114
+ "id": {"$ref": "#/$defs/jsonScalar"}
115
+ }
116
+ }
117
+ }
118
+ }
119
+ },
120
+ {
121
+ "if": {"properties": {"check": {"const": "geodata.feature_field_equals"}}},
122
+ "then": {
123
+ "properties": {
124
+ "args": {
125
+ "type": "object",
126
+ "required": ["path", "id_field", "id", "field", "equals"],
127
+ "additionalProperties": false,
128
+ "properties": {
129
+ "path": {"$ref": "#/$defs/safeProjectPath"},
130
+ "id_field": {"$ref": "#/$defs/fieldIdentifier"},
131
+ "id": {"$ref": "#/$defs/jsonScalar"},
132
+ "field": {"$ref": "#/$defs/fieldIdentifier"},
133
+ "equals": {"$ref": "#/$defs/jsonScalar"}
134
+ }
135
+ }
136
+ }
137
+ }
138
+ },
139
+ {
140
+ "if": {"properties": {"check": {"const": "geodata.field_range"}}},
141
+ "then": {
142
+ "properties": {
143
+ "args": {
144
+ "type": "object",
145
+ "required": ["path", "field"],
146
+ "additionalProperties": false,
147
+ "properties": {
148
+ "path": {"$ref": "#/$defs/safeProjectPath"},
149
+ "field": {"$ref": "#/$defs/fieldIdentifier"},
150
+ "min": {"type": "number"},
151
+ "max": {"type": "number"}
152
+ },
153
+ "anyOf": [
154
+ {"required": ["min"]},
155
+ {"required": ["max"]}
156
+ ]
157
+ }
158
+ }
159
+ }
160
+ }
161
+ ]
162
+ }
163
+ },
164
+ "properties": {
165
+ "schema": {"const": "openmapstack-project/v1"},
166
+ "project": {
167
+ "type": "object",
168
+ "required": ["id", "title", "question", "created_at", "updated_at", "status"],
169
+ "properties": {
170
+ "id": {"type": "string", "minLength": 1},
171
+ "title": {"type": "string", "minLength": 1},
172
+ "question": {"type": "string", "minLength": 1},
173
+ "created_at": {"type": "string", "minLength": 1},
174
+ "updated_at": {"type": "string", "minLength": 1},
175
+ "status": {"enum": ["draft", "in_progress", "validated", "warning", "failed"]}
176
+ }
177
+ },
178
+ "interpretation": {
179
+ "type": "object",
180
+ "required": ["objective", "assumptions"],
181
+ "properties": {
182
+ "objective": {"type": "string", "minLength": 1},
183
+ "assumptions": {"type": "array"}
184
+ }
185
+ },
186
+ "sources": {"type": "object", "minProperties": 1},
187
+ "overrides": {"type": "array"},
188
+ "processing": {
189
+ "type": "object",
190
+ "required": ["analysis_crs", "steps"],
191
+ "properties": {
192
+ "analysis_crs": {"type": "string", "minLength": 1},
193
+ "storage_crs": {"type": "string", "minLength": 1},
194
+ "steps": {"type": "array", "minItems": 1}
195
+ }
196
+ },
197
+ "outputs": {"type": "object", "minProperties": 1},
198
+ "validation": {
199
+ "type": "object",
200
+ "required": ["required", "domain_checks"],
201
+ "properties": {
202
+ "required": {"type": "array"},
203
+ "domain_checks": {"type": "array"},
204
+ "expectations": {
205
+ "type": "array",
206
+ "items": {"$ref": "#/$defs/expectation"}
207
+ }
208
+ }
209
+ },
210
+ "presentation": {
211
+ "type": "object",
212
+ "properties": {
213
+ "map": {
214
+ "type": "object",
215
+ "required": ["basemap"],
216
+ "properties": {
217
+ "basemap": {
218
+ "type": "object",
219
+ "required": ["id", "attribution"],
220
+ "properties": {
221
+ "id": {"type": "string", "minLength": 1},
222
+ "kind": {"enum": ["raster-xyz", "raster-wms", "vector-style"]},
223
+ "tiles": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
224
+ "url": {"type": "string", "minLength": 1},
225
+ "attribution": {"type": "string", "minLength": 1},
226
+ "default_visible": {"type": "boolean"}
227
+ },
228
+ "anyOf": [{"required": ["tiles"]}, {"required": ["url"]}]
229
+ }
230
+ }
231
+ }
232
+ }
233
+ },
234
+ "runtime": {
235
+ "type": "object",
236
+ "required": ["implementation"],
237
+ "properties": {
238
+ "implementation": {
239
+ "type": "object",
240
+ "anyOf": [
241
+ {"required": ["pipeline"]},
242
+ {"required": ["command"]}
243
+ ]
244
+ }
245
+ }
246
+ },
247
+ "runs": {
248
+ "type": "object",
249
+ "required": ["latest"],
250
+ "properties": {
251
+ "latest": {
252
+ "type": "object",
253
+ "required": [
254
+ "id", "started_at", "completed_at", "status",
255
+ "inputs_hash", "outputs_hash", "validation_report"
256
+ ],
257
+ "properties": {
258
+ "status": {"enum": ["passed", "warning", "failed"]}
259
+ }
260
+ }
261
+ }
262
+ }
263
+ }
264
+ }