Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions src/agents_shipgate/cli/verify/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
VerifierBaseStatus,
VerifierHumanReview,
VerifierNextAction,
map_merge_verdict,
merge_verdict_for,
)
from agents_shipgate.triggers import evaluate

Expand Down Expand Up @@ -513,12 +513,6 @@ def _map_optional_tree_path(
return tree_dir / relative


def _merge_verdict(*, decision: str | None, head_status: str) -> MergeVerdict:
if decision is not None:
return map_merge_verdict(decision)
return "mergeable" if head_status == "skipped" else "unknown"


def _can_merge_without_human(
*, merge_verdict: MergeVerdict, release_decision: ReleaseDecision | None
) -> bool:
Expand Down Expand Up @@ -642,7 +636,7 @@ def _build_verifier(
include_scan_artifacts=report is not None,
)
decision = release_decision_model.decision if release_decision_model else None
merge_verdict = _merge_verdict(decision=decision, head_status=head_status)
merge_verdict = merge_verdict_for(decision=decision, head_status=head_status)
human_review = _human_review(
merge_verdict=merge_verdict, release_decision=release_decision_model
)
Expand Down
13 changes: 6 additions & 7 deletions src/agents_shipgate/schemas/capability_change.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@

from pydantic import BaseModel, ConfigDict, Field, model_validator

from agents_shipgate.schemas.common import Confidence
from agents_shipgate.schemas.common import Confidence, ReleaseDecisionStatus

# --- Capability change -------------------------------------------------------

Expand Down Expand Up @@ -327,12 +327,11 @@ def _sort_lists(self) -> HumanAck:

# --- Verifier summary --------------------------------------------------------

VerifierVerdict = Literal[
"blocked",
"review_required",
"insufficient_evidence",
"passed",
]
# Back-compat alias for the canonical verdict vocabulary. ``VerifierSummary.verdict``
# MUST mirror ``release_decision.decision`` (Principle 2), so it reuses the exact
# same ``ReleaseDecisionStatus`` enum rather than re-spelling it. Kept as a named
# alias because callers and tests import ``VerifierVerdict`` directly.
VerifierVerdict = ReleaseDecisionStatus


class VerifierCapabilityDeltaSummary(BaseModel):
Expand Down
13 changes: 13 additions & 0 deletions src/agents_shipgate/schemas/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@
Severity = Literal["info", "low", "medium", "high", "critical"]
Confidence = Literal["low", "medium", "high"]
BaselineStatus = Literal["new", "matched", "resolved"]
# The canonical release-verdict vocabulary — the ONE enum the whole system
# gates on. ``build_release_decision()`` is the only place that computes it;
# every other "verdict"/"decision" field (AgentSummary, ReviewerSummary,
# VerifierSummary, ReleaseConsequence) re-uses this exact alias so the
# vocabulary can never be re-spelled or drift out of lockstep. The
# agent-facing ``MergeVerdict`` (schemas/verifier.py) is a deterministic
# projection of this via ``map_merge_verdict()``.
ReleaseDecisionStatus = Literal[
"blocked",
"review_required",
"insufficient_evidence",
"passed",
]
# v0.15: per-finding provenance kind. Independent of `confidence` —
# `confidence` records how sure a rule is; `provenance_kind` records
# *what kind of rule fired* (and what artifact it inspected). Lets
Expand Down
35 changes: 12 additions & 23 deletions src/agents_shipgate/schemas/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
BaselineStatus,
Confidence,
ProvenanceKind,
ReleaseDecisionStatus,
Severity,
SourceReference,
)
Expand Down Expand Up @@ -149,14 +150,11 @@ class BaselineSummary(BaseModel):


# v0.8: release_decision block — see docs/STABILITY.md for the
# divergence contract with summary.status (which stays baseline-blind
# for backwards compatibility).
ReleaseDecisionStatus = Literal[
"blocked",
"review_required",
"insufficient_evidence",
"passed",
]
# divergence contract with summary.status (which stays baseline-blind for
# backwards compatibility). The ``ReleaseDecisionStatus`` verdict vocabulary
# is now defined once in schemas/common.py and imported above, so the report
# summaries, ReleaseConsequence, and the verifier projection all share the
# exact same enum (one decision engine — no re-spelling, no drift).


class ReleaseDecisionItem(BaseModel):
Expand Down Expand Up @@ -386,12 +384,7 @@ class AgentSummary(BaseModel):

model_config = ConfigDict(extra="forbid")

verdict: Literal[
"blocked",
"review_required",
"insufficient_evidence",
"passed",
]
verdict: ReleaseDecisionStatus
headline: str
blocker_count: int = 0
review_item_count: int = 0
Expand Down Expand Up @@ -464,15 +457,11 @@ class ReviewerSummary(BaseModel):

model_config = ConfigDict(extra="forbid")

# Mirror the release verdict for at-a-glance context. Same enum as
# ``AgentSummary.verdict`` so a downstream consumer can switch on
# either block without re-deriving.
verdict: Literal[
"blocked",
"review_required",
"insufficient_evidence",
"passed",
]
# Mirror the release verdict for at-a-glance context. The exact same
# ``ReleaseDecisionStatus`` alias as ``AgentSummary.verdict`` and
# ``release_decision.decision`` so a downstream consumer can switch on
# any block without re-deriving — and the vocabulary cannot drift.
verdict: ReleaseDecisionStatus
headline: str

# Per-lens activity counts. Each is the cheapest "did this lens
Expand Down
68 changes: 64 additions & 4 deletions src/agents_shipgate/schemas/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator

from agents_shipgate.schemas.common import ReleaseDecisionStatus

VerifierBaseStatus = Literal[
"not_requested",
Expand Down Expand Up @@ -32,7 +34,14 @@
"none",
]

_DECISION_TO_VERDICT: dict[str, MergeVerdict] = {
# The projection from the canonical release verdict (``ReleaseDecisionStatus``,
# the ONE thing ``build_release_decision`` computes) onto the agent-facing
# ``MergeVerdict``. Keyed with ``ReleaseDecisionStatus`` so a key that is not a
# real release status is a type error, and covered by a totality test
# (tests/test_verdict_contract.py) so adding a release status without a mapping
# fails CI rather than silently falling back. This dict is the only bridge
# between the two vocabularies.
_DECISION_TO_VERDICT: dict[ReleaseDecisionStatus, MergeVerdict] = {
"passed": "mergeable",
"review_required": "human_review_required",
"insufficient_evidence": "insufficient_evidence",
Expand All @@ -41,10 +50,30 @@


def map_merge_verdict(decision: str | None) -> MergeVerdict:
"""Project ``release_decision.decision`` onto a merge verdict."""
"""Project ``release_decision.decision`` onto a merge verdict.

``None`` (no head scan / no decision) is ``unknown``. A decision string
outside the canonical vocabulary fails safe to ``human_review_required``
rather than ``mergeable`` — an unrecognized verdict must never auto-pass.
"""
if decision is None:
return "unknown"
return _DECISION_TO_VERDICT.get(decision, "human_review_required")
return _DECISION_TO_VERDICT.get(decision, "human_review_required") # type: ignore[arg-type]


def merge_verdict_for(*, decision: str | None, head_status: str) -> MergeVerdict:
"""Single authority for deriving a ``MergeVerdict`` for a verify run.

When the head scan produced a ``release_decision`` the verdict is a pure
projection of it (``map_merge_verdict``). With no decision the verdict
reflects *why*: a skipped head (Shipgate had nothing to gate) is
``mergeable``; any other no-decision state (scan failed, or not yet run)
is ``unknown``. Centralized here so the orchestrator — or any future
caller — cannot invent a second, inconsistent rule.
"""
if decision is not None:
return map_merge_verdict(decision)
return "mergeable" if head_status == "skipped" else "unknown"


class VerifierNextAction(BaseModel):
Expand Down Expand Up @@ -141,6 +170,36 @@ class VerifierArtifact(BaseModel):
first_next_action: VerifierNextAction | None = None
artifacts: dict[str, str] = Field(default_factory=dict)

@model_validator(mode="after")
def _verdict_projects_release_decision(self) -> VerifierArtifact:
"""Lock the one-decision-engine contract structurally.

Whenever a head ``release_decision`` is present, the agent-facing
``merge_verdict`` and the convenience ``decision`` copy MUST be exact
projections of it — never an independently computed second opinion.
Construction-time enforcement makes an inconsistent artifact
impossible to emit. (No release_decision — skipped / failed / preview
— is left unconstrained: there is no substrate to project.)
"""
if self.release_decision is None:
return self
substrate = self.release_decision.get("decision")
if self.decision != substrate:
raise ValueError(
"VerifierArtifact.decision must equal "
"release_decision['decision'] (one decision engine): "
f"{self.decision!r} != {substrate!r}"
)
expected = map_merge_verdict(substrate)
if self.merge_verdict != expected:
raise ValueError(
"VerifierArtifact.merge_verdict must be the projection of "
f"release_decision['decision']={substrate!r} via "
f"map_merge_verdict (expected {expected!r}, got "
f"{self.merge_verdict!r})"
)
return self


__all__ = [
"CapabilityChangeBucket",
Expand All @@ -154,4 +213,5 @@ class VerifierArtifact(BaseModel):
"VerifierHumanReview",
"VerifierNextAction",
"map_merge_verdict",
"merge_verdict_for",
]
Loading