# verification-service Phase-1 Verifier for the ProofedMesh two-stage trust chain (`approval → transform`) per [DEV-612](/DEV/issues/DEV-612), parent [DEV-606](/DEV/issues/DEV-606). Validates an end-to-end chain for `sink:dummy-sink` and reserves a `decision_id` exactly once so a replayed sink delivery can be refused without re-running the signature math. ## Endpoint ``` POST /api/v1/verify/chain Content-Type: application/json { "decision_id": "dec-123", "approval_ref": "sha256:<64-hex>", "transform_ref": "sha256:<64-hex>", "payload_hash": "sha256:<64-hex>", "sink_target": "sink:dummy-sink" } ``` Response (always 200 unless the TLog is unreachable): ``` { "valid": true | false, "replay": true | false, "decision_id": "dec-123", "actors": ["trust-api/approval-signer-2026-q4", "trust-api/agent-testagent-transform-2026-q4"], "approval_ref": "sha256:…", "transform_ref": "sha256:…", "reasons": ["", …] } ``` The sink (dummy-sink) maps `replay: true` to **409 Conflict** and the rest of `valid: false` to **403 Forbidden**; the verification-service itself only uses non-200 status codes for transport failures (502 when the transparency-log is unavailable, 400 for malformed bodies). The handler also accepts `target_sink` as a legacy alias for `sink_target` so the DEV-612 scope wording stays wire-compatible. ### Chain rules (hard-coded, Phase 1) In order; the first failure wins and populates `reasons`: 1. **DSSE signatures.** - Approval signed by `trust-api/approval-signer-2026-q4` (configurable via `APPROVAL_SIGNER_KEY_ID`). - Transform signed by `trust-api/agent-testagent-transform-2026-q4` (configurable via `TRANSFORM_SIGNER_KEY_ID`). - Public keys loaded from `VERIFICATION_SIGNER_KEYS` as a comma- / newline-separated spec: `trust-api/approval-signer-2026-q4=pem:,trust-api/agent-testagent-transform-2026-q4=hex:<64-hex>`. Supported encodings: `pem`, `hex`, `b64`, `b64u`. 2. **Transparency-log inclusion proofs.** RFC 6962 §2.1.1; the proof returned by the TLog must close against the `tree_root_hash` for both envelopes. 3. **Content-binding hash.** - `approval.subject.digest.sha256` matches `strip("sha256:", approval.predicate.content_hash)`. - `transform.subject.digest.sha256` matches `strip("sha256:", request.payload_hash)`. 4. **`allowed_transformations`** on the approval predicate covers the transform predicate's `operation`. 5. **`allowed_sinks`** on the approval predicate contains the chain request's `sink_target`. 6. **Replay guard.** `decision_id` is reserved by the replay store (`verification_decisions` table). A second call with the same id returns `replay: true, valid: false`. Approval predicates MUST additionally carry `expires_at` as an RFC 3339 timestamp. A verification call after that instant returns `approval_expired` and does not reserve the decision. ### Reason codes Stable strings; dashboards grep them. - `bad_request` - `approval_fetch_failed`, `transform_fetch_failed` - `approval_signature_unknown_key`, `approval_signature_invalid` - `transform_signature_unknown_key`, `transform_signature_invalid` - `approval_inclusion_proof_invalid`, `transform_inclusion_proof_invalid` - `approval_predicate_malformed`, `transform_predicate_malformed` - `approval_hash_drift`, `transform_hash_drift` - `operation_not_in_allowed_transformations` - `sink_not_in_allowed_sinks` - `approval_expired` - `replay_decision_id` ### Expected predicate fields The verifier is strict about the fields it reads. The approval predicate (`basicstack.de/attestations/approval/v1`) MUST carry: - `content_hash` — `sha256:` of the approved canonical payload. - `allowed_transformations: ["", …]` - `allowed_sinks: ["", …]` - `expires_at` — RFC 3339 timestamp. The transform predicate (`basicstack.de/attestations/transform/v1`) MUST carry: - `operation` — the OPA-vetted transformation name (e.g. `expense.map_to_sink`). The approval-service (DEV-608) currently emits `content_hash`, `performed_by`, `performed_at`, `verification_digits` and `approval_schema`. The three new fields (`allowed_transformations`, `allowed_sinks`, `expires_at`) must be added to the approval predicate upstream before the Phase-1 end-to-end smoke ([DEV-613](/DEV/issues/DEV-613)) can pass; this verifier returns `approval_predicate_malformed` until they are present. ## Configuration | Env var | Flag | Default | Required | | -------------------------- | ----------------------- | --------------------------------------------------------- | -------- | | `TLOG_URL` | `-tlog-url` | — | yes | | `TLOG_READ_TOKEN` | `-tlog-token` | empty | no | | `APPROVAL_SIGNER_KEY_ID` | `-approval-key-id` | `trust-api/approval-signer-2026-q4` | no | | `TRANSFORM_SIGNER_KEY_ID` | `-transform-key-id` | `trust-api/agent-testagent-transform-2026-q4` | no | | `VERIFICATION_SIGNER_KEYS` | `-signer-keys` | — | yes | | `VERIFICATION_STORE_BACKEND` | `-backend` | `memory` | no | The replay store is in-memory in Phase 1. The Postgres backend is a stub mirroring the pattern used by the approval-service's Postgres skeleton; the schema is authoritative in `db/trust/migrations/0005_verification_decisions.{up,down}.sql`. ## Build + test ``` cd services/verification-service go vet ./... go test ./... ``` The CI pipeline (`.forgejo/workflows/validate.yml`) iterates every `services/*/go.mod` so the module is picked up automatically. ## Non-scope (Phase 2) - A second transform agent / N-stage chains. - OPA for verification. The rules above are hard-coded here; moving them to OPA is tracked under the Phase-2 chain-policy work. - Live agent-registry lookups for transform signer keys. The `registry.Keyring` interface is the swap-point when the HTTP-backed registry lands.