Trusted promotion — one fail-closed answer to "may this ship?"¶
Evaluation, statistics, proofs, canaries and policy admission exist as separate
modules. promote() collects their reports into one decision and refuses to
guess.
from agentdescent import (
PromotionRequest, PromotionRules, ProofEvidence, promote,
)
decision = promote(PromotionRequest(
artifact_id="skill-1",
artifact_hash=proof.certificate.artifact_hash,
eval_result=eval_result, # agentdescent.evalops.EvalResult
contaminated=False,
conformal=conformal_report, # agentdescent.conformal.ConformalReport
sequential=sequential_report, # agentdescent.sequential.SequentialReport
canary=canary_result, # agentdescent.safety_controller.CanaryResult
proof=ProofEvidence(certificate, verification),
admission=admission, # agentdescent.policy_release.AdmissionResult
risk=0.2,
approved=True,
), PromotionRules(require_conformal=True, require_proof=True,
require_admission=True))
decision.action, decision.reasons, decision.decision_hash
Assume the reports come from evalops, conformal, sequential,
safety_controller.canary, proofs and policy_release; this module consumes
them and never re-runs them.
The decision lattice¶
| Evidence state | Action |
|---|---|
| a required report is missing | defer |
| a gate fails, a proof is invalid, risk is too high, approval was denied | reject |
| a canary says roll back | rollback |
| every required gate is green | promote |
rollback outranks the other failures: a measured regression is an incident,
not a pending approval. An admission whose signature is invalid is a hard
reject.
Two approval controls are separate:
admission.needs_approval(the policy effectrequire_approval) defers the decision regardless of the localrequire_approvalflag;require_approvalgovernsrequest.approved:Nonedefers,Truepasses, and an explicitFalseis a hardrejecteven when the flag is off. A caller that supplies "no" is not asking for the default.
Rules, not a feature list¶
PromotionRules says what must be present and the bars it must clear:
require_eval,require_contamination,require_risk,require_conformal,require_sequential,require_canary,require_proof,require_admission,require_approval;min_effect,min_tasks,max_risk,allow_contaminated.
require_risk defaults to True, so a request without risk defers by
default. Supplied evidence is judged even when its require_* flag is off: a
supplied canary rollback or invalid proof still rejects. A flag only decides
whether the absence of that evidence defers the decision.
Evidence is bound to the artifact¶
A green certificate for skill-1 must not promote skill-EVIL:
proof.certificate.artifact_idmust equalrequest.artifact_id(always);- if
artifact_hashis supplied, the proof certificate and the admission manifest hash must equal it.
A mismatched binding is a reject, not a warning. Hash binding only applies
when artifact_hash is supplied, so always pass it when a proof or admission is
present. The decision is then safe to carry into a manifest or a lineage
record.
Two hashes¶
evidence_hashidentifies the inputs (the reports, risk, approval, metadata). The same evidence always hashes the same.decision_hashcoversevidence_hash+asdict(PromotionRules)+action. Two different rule sets that reach different verdicts cannot share a decision hash.
PromotionDecision.to_dict() is JSON-serialisable and is what a decision log
should store.
What it does not do¶
It does not re-run the gates. evalops, conformal, sequential, canary,
proofs and policy_release produce the reports; this module owns the
ordering, the missing-evidence policy and the audit record. That keeps a
non-deterministic judge or a billable verifier from being called twice.
Release receipt¶
PromotionLedger writes an accepted promotion into the release path:
from agentdescent import PromotionLedger, PromotionRules
from agentdescent.lineage import LineageGraph
rules = PromotionRules(...)
decision = promote(request, rules)
ledger = PromotionLedger(LineageGraph(), issuer="ci")
release = ledger.record(
decision, artifact_id="skill-1", content_hash=content_hash,
key_id="ci", secret=key, rules=rules,
parents=("skill-0",), run_id="run-8")
release.verification.valid
It refuses a decision for another artifact, a mismatched content hash, a
tampered evidence/decision hash, wrong rules, or a duplicate lineage id before
producing a signature. The manifest carries the issuer; to_dict() includes the
envelope and hashes needed to re-verify offline.