5775a97388
REQ-217: schemas/submission-readiness.schema.json (JSON Schema draft 2020-12) defines acceptable-to-start as a superset gate above contract.schema.json: contractId, environment, tags (5 Nova tags D-054), policyPreconditions, profile (developer|agentic), appSource (repo+ref), per-env mandatory (W3.E: qa→e2eSuite+loadTest, prod→runbook+dashboard+oncall, dr→drDrillRef), agentic markers (naturalLanguageIntent+confidenceAtSubmission+agentTrace). REQ-218: core/submission_readiness.py validator with check_readiness() + ReadinessResult (structured pass/fail + reason codes). Wired as contract_ingestor.py --check-readiness (D-133). Reason codes: MISSING_TAGS, ENV_MISSING_MANDATORY, AGENTIC_MISSING_INTENT, MISSING_APP_SOURCE, POLICY_PRECONDITION_MISSING. Never raises — all failures are reason codes. REQ-219: docs/submission-readiness.md (good + rejected examples + reason-code catalog + compliance-standard equivalence). REQ-220: tests/test_submission_readiness.py — 16 tests, all pass. Covers: good-pass, good-agentic-pass, missing-tags, empty-tag, qa-missing-e2e, prod-missing-runbook, dr-missing-drdrill, prod-all-pass, agentic-missing-all, agentic-missing-one, missing-appsource, appsource-missing-ref, empty-policy, result-structure. ---ci--- project: acdl phase: 3 milestone: v1.18 status: execute requirements: covered: [REQ-217, REQ-218, REQ-219, REQ-220] partial: [] ---/ci---
193 lines
6.7 KiB
Python
193 lines
6.7 KiB
Python
"""core/submission_readiness.py — Nova submission-readiness validator (REQ-218).
|
|
|
|
Defines what is acceptable to start — a superset gate ABOVE
|
|
contract.schema.json validity. Invoked as
|
|
``contract_ingestor.py --check-readiness`` (D-133). Returns a structured
|
|
ReadinessResult (pass/fail per check, with reason codes). On fail → the
|
|
ingestor rejects with a citizen-developer-facing error (not a stack
|
|
trace). On pass → proceeds to existing contract ingestion.
|
|
|
|
The validator calls contract.schema.json validation first (the shape),
|
|
then the readiness checks (the gate): tags, env mandatory, policy
|
|
preconditions, profile:agentic markers, appSource.
|
|
|
|
Reason codes:
|
|
MISSING_TAGS — one or more required Nova tags are absent
|
|
ENV_MISSING_MANDATORY:<env>:<field> — a per-env mandatory field is missing
|
|
AGENTIC_MISSING_INTENT — profile=agentic but naturalLanguageIntent absent
|
|
MISSING_APP_SOURCE — appSource (repo + ref) is missing
|
|
POLICY_PRECONDITION_MISSING — a declared policy precondition is absent
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
_SCHEMA_DIR = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "schemas"
|
|
)
|
|
|
|
REQUIRED_TAGS = [
|
|
"nova:owner",
|
|
"nova:contract",
|
|
"nova:environment",
|
|
"nova:cost-center",
|
|
"nova:ref",
|
|
]
|
|
|
|
ENV_MANDATORY: dict[str, list[str]] = {
|
|
"dev": [], # dev requires only the base contract shape (id+environment+infrastructure)
|
|
"qa": ["validation.e2eSuite", "validation.loadTest"],
|
|
"prod": ["runbook", "dashboard", "oncall"],
|
|
"dr": ["drDrillRef"],
|
|
}
|
|
|
|
AGENTIC_REQUIRED = ["naturalLanguageIntent", "confidenceAtSubmission", "agentTrace"]
|
|
|
|
|
|
@dataclass
|
|
class ReadinessResult:
|
|
"""Structured result of the submission-readiness gate."""
|
|
|
|
ready: bool
|
|
reason_codes: list[str] = field(default_factory=list)
|
|
contract_id: str | None = None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"ready": self.ready,
|
|
"reason_codes": self.reason_codes,
|
|
"contractId": self.contract_id,
|
|
}
|
|
|
|
def __str__(self) -> str:
|
|
if self.ready:
|
|
return f"READY — contract {self.contract_id} passes submission-readiness gate"
|
|
codes = "; ".join(self.reason_codes) if self.reason_codes else "unknown"
|
|
return f"NOT READY — contract {self.contract_id}: {codes}"
|
|
|
|
|
|
def _validate_contract_schema(contract: dict[str, Any]) -> list[str]:
|
|
"""Validate the contract against contract.schema.json (the shape).
|
|
Returns a list of reason codes (empty if valid). Falls back to no-op
|
|
if jsonschema or the schema file is unavailable (the contract is
|
|
validated upstream by run_platform.sh in the normal path).
|
|
"""
|
|
codes: list[str] = []
|
|
try:
|
|
import jsonschema
|
|
|
|
schema_path = os.path.join(_SCHEMA_DIR, "contract.schema.json")
|
|
with open(schema_path) as f:
|
|
schema = json.load(f)
|
|
jsonschema.validate(instance=contract, schema=schema)
|
|
except (OSError, ImportError):
|
|
pass
|
|
except jsonschema.ValidationError as e:
|
|
codes.append(f"CONTRACT_SCHEMA_INVALID:{e.message}")
|
|
return codes
|
|
|
|
|
|
def _get_nested(data: dict[str, Any], dotted_key: str) -> Any:
|
|
parts = dotted_key.split(".")
|
|
val: Any = data
|
|
for p in parts:
|
|
if not isinstance(val, dict) or p not in val:
|
|
return None
|
|
val = val[p]
|
|
return val
|
|
|
|
|
|
def check_readiness(submission: dict[str, Any]) -> ReadinessResult:
|
|
"""Run the full submission-readiness gate.
|
|
|
|
1. Validate the contract shape (contract.schema.json).
|
|
2. Validate the readiness schema (submission-readiness.schema.json).
|
|
3. Run the semantic readiness checks (tags, env mandatory, agentic, appSource, policy).
|
|
|
|
Returns a ReadinessResult. Never raises — all failures are reason codes.
|
|
"""
|
|
contract_id = submission.get("contractId") or submission.get("id", "unknown")
|
|
codes: list[str] = []
|
|
|
|
# Step 1: contract shape validation
|
|
contract_shape = {k: v for k, v in submission.items() if k in ("id", "name", "environment", "infrastructure")}
|
|
if contract_shape:
|
|
codes.extend(_validate_contract_schema(contract_shape))
|
|
|
|
# Step 2: readiness schema validation
|
|
try:
|
|
import jsonschema
|
|
|
|
schema_path = os.path.join(_SCHEMA_DIR, "submission-readiness.schema.json")
|
|
with open(schema_path) as f:
|
|
readiness_schema = json.load(f)
|
|
jsonschema.validate(instance=submission, schema=readiness_schema)
|
|
except (OSError, ImportError):
|
|
pass
|
|
except jsonschema.ValidationError as e:
|
|
codes.append(f"READINESS_SCHEMA_INVALID:{e.message}")
|
|
|
|
# Step 3: semantic checks (reason codes for citizen-developer-facing errors)
|
|
|
|
# 3a: tags
|
|
tags = submission.get("tags", {})
|
|
missing_tags = [t for t in REQUIRED_TAGS if t not in tags or not tags[t]]
|
|
if missing_tags:
|
|
codes.append(f"MISSING_TAGS:{','.join(missing_tags)}")
|
|
|
|
# 3b: env mandatory (W3.E per-env table)
|
|
env = submission.get("environment")
|
|
if env and env in ENV_MANDATORY:
|
|
for field_key in ENV_MANDATORY[env]:
|
|
val = _get_nested(submission, field_key)
|
|
if val is None:
|
|
codes.append(f"ENV_MISSING_MANDATORY:{env}:{field_key}")
|
|
|
|
# 3c: agentic profile markers
|
|
if submission.get("profile") == "agentic":
|
|
for marker in AGENTIC_REQUIRED:
|
|
if not submission.get(marker):
|
|
codes.append(f"AGENTIC_MISSING_INTENT:{marker}")
|
|
|
|
# 3d: appSource
|
|
app_source = submission.get("appSource")
|
|
if not app_source or not app_source.get("repo") or not app_source.get("ref"):
|
|
codes.append("MISSING_APP_SOURCE")
|
|
|
|
# 3e: policy preconditions (warn if declared but not enforced this milestone)
|
|
policy = submission.get("policyPreconditions", {})
|
|
if not policy:
|
|
codes.append("POLICY_PRECONDITION_MISSING")
|
|
|
|
ready = len(codes) == 0
|
|
return ReadinessResult(ready=ready, reason_codes=codes, contract_id=contract_id)
|
|
|
|
|
|
def cli_main(argv: list[str]) -> int:
|
|
"""CLI entry: python3 -m core.submission_readiness <contract.json>
|
|
|
|
Also invoked via contract_ingestor.py --check-readiness (D-133).
|
|
Prints the ReadinessResult to stdout; exits 0 if ready, 1 if not.
|
|
"""
|
|
if len(argv) < 2:
|
|
print("Usage: submission_readiness <contract.json>", file=sys.stderr)
|
|
return 2
|
|
path = argv[1]
|
|
try:
|
|
with open(path) as f:
|
|
submission = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
print(f"ERROR: cannot read {path}: {e}", file=sys.stderr)
|
|
return 2
|
|
result = check_readiness(submission)
|
|
print(result)
|
|
print(json.dumps(result.to_dict(), indent=2))
|
|
return 0 if result.ready else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(cli_main(sys.argv)) |