Skip to main content

Signed-Ticket Production Promotion

This guide is the operator walkthrough for the OpenSSH signed-ticket design that gates PRODUCTION_PROMOTION. It is derived from scripts/goal-runner/signed_ticket_schema.py and signed_ticket_verifier.py.

Never put a real private key in the ASF50 repository, runtime, evidence, chat, or this documentation. The walkthrough below uses placeholders.

Design summary

A signed ticket is an OpenSSH-signed authorization payload that the orchestrator verifies with ssh-keygen -Y verify. The payload binds:

  • ticket_id — a UUID identifying the ticket.
  • gate_id — the gate this ticket authorizes.
  • goal_id — the goal this ticket authorizes.
  • subject_sha — the exact commit SHA being authorized.
  • production_target — the environment identifier (e.g., staging, canary, live).
  • issuer — the human principal that signs.
  • namespace — the OpenSSH signer namespace.
  • payload_digest — a hex digest of the operation payload.
  • created_at — UTC ISO-8601 timestamp.

The signature is verified against agent-harness/governance/allowed_signers.

Operator signing walkthrough

Step 1 — generate the ticket payload

On your workstation, with the private key available only locally:

GOAL_ID="ASF50_DOCS_PORTAL_001"
GATE_ID="00000000-0000-0000-0000-000000000000"
SUBJECT_SHA="$(git -C /path/to/repo rev-parse HEAD)"
PRODUCTION_TARGET="staging"

cat > payload.json <<JSON
{
"ticket_id": "$(uuidgen)",
"gate_id": "$GATE_ID",
"goal_id": "$GOAL_ID",
"subject_sha": "$SUBJECT_SHA",
"production_target": "$PRODUCTION_TARGET",
"issuer": "asf50-owner",
"namespace": "asf50-production",
"payload_digest": "$(sha256sum payload.json | awk '{print $1}')",
"created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
JSON

Step 2 — sign the payload with ssh-keygen -Y sign

ssh-keygen -Y sign \
-f ~/.ssh/asf50_owner_ed25519 \
-n asf50-production \
-s payload.json.sig \
< payload.json

This produces payload.json.sig next to the payload. The signature file is the artifact you submit to the orchestrator.

Step 3 — submit the signature to ASF50

Submit the signature via the orchestrator's signed-ticket ingestion CLI (referenced from scripts/goal-runner/signed_ticket_verifier.py):

.\scripts\goal-runner\goal-gate.ps1 `
-Action approve `
-GateId $gateId `
-Approver "<GitHub login or owner display name>" `
-Source signed-ticket `
-Note "<bounded note>" `
-EvidencePath "<path-to-payload.json.sig>"

The orchestrator verifies with:

ssh-keygen -Y verify \
-f agent-harness/governance/allowed_signers \
-I asf50-owner \
-n asf50-production \
-s <signature-file> \
< payload.json

Step 4 — orchestrator consumes the gate

If verification succeeds, the orchestrator consumes the gate, transitions the goal toward FROZEN_PASS, and emits the deployment step.

Verification-before-approval

The orchestrator verifies the signature before marking the gate as approved. A failed verification leaves the gate OPEN.

Approval-before-consumption

The orchestrator marks the gate approved only after verification. Consumption happens only when the orchestrator actually performs the production action.

Replay protection

After consumption, the ticket_id is recorded as consumed. A second submission of the same ticket_id is rejected.

Subject binding

If payload.subject_sha does not match the runtime HEAD, the ticket is rejected. Re-base the work and issue a fresh ticket at the new head.

What the private key is NOT

  • Not in the repo.
  • Not in the runtime DB.
  • Not in evidence/.
  • Not in chat history.
  • Not in this documentation.

Rotating the signing key

See Playbooks → Rotate the production signing key.