# dummy-sink Phase-1 Mock-Senke (`sink:dummy-sink`) per [DEV-611](/DEV/issues/DEV-611), parent [DEV-606](/DEV/issues/DEV-606). Trivial sink adapter that implements the §7 Senken-Vertrag of the architecture V1.1 at the minimum needed to close the Phase-1 trust chain: it accepts a decision, calls the verification-service, persists the payload to local storage, and emits a signed handover attestation. ## Endpoint ``` POST /api/v1/sink/dummy/deliver Content-Type: application/json { "decision_id": "dec-123", "payload_hash": "sha256:<64-hex>", "payload": { ... }, "approval_ref": "sha256:<64-hex>", "transform_ref": "sha256:<64-hex>" } ``` `payload_hash` must match either `sha256(raw payload bytes)` or `sha256(canonical(payload))` — a verifier can reproduce either form. ### Flow 1. Verify the submitted hash matches `payload`. 2. `POST {VERIFY_URL}/api/v1/verify/chain` with `{decision_id, payload_hash, approval_ref, transform_ref, sink_target}`. 3. Branch on the verdict: - `valid: true` → 1. Write `payload` atomically to `{DATA_DIR}/{decision_id}.json` (`.tmp` + rename). 2. Build the handover statement (predicateType `https://basicstack.de/attestations/handover/v1`) that binds `decision_id`, `payload_hash`, `approval_ref`, `transform_ref`, and `sink_record_ref` into the trust chain. 3. `POST {SIGNING_URL}/api/v1/sign/dsse` with the double identity headers (Pocket-ID bearer + projected SA token). 4. `POST {TLOG_URL}/api/v1/log/entries` with the signed envelope + `content_hash`. 5. `201 Created { handover_ref, log_index, decision_id }`. - `valid: false, replay: true` → `409 Conflict`. - `valid: false` → `403 Forbidden { error: { reasons: [...] } }`. Replay protection intentionally lives in the verification-service (parent plan §Subtask 6); dummy-sink keeps no replay table. A second delivery with the same `decision_id` is funneled through the verification-service, which marks it as a replay and the sink translates that to `409`. ## Non-scope - Real sinks (DB-App, SAP, webhooks) — all in Phase 4+. - No request authentication of the inbound caller; dummy-sink is reachable only from in-cluster callers behind NetworkPolicy (Phase-1-minimum). - No local replay store; the verification-service owns `decision_id` consumption. ## Configuration | Flag / env | Default | Purpose | | ----------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------- | | `-listen` | `:8083` | HTTP listen address. | | `-data-dir` | `/data/dummy-sink` | Where delivered payloads are written as `.json`. | | `-verify-url` | *(required)* | Base URL of verification-service. | | `-signing-url` | *(required)* | Base URL of signing-service. | | `-tlog-url` | *(required)* | Base URL of transparency-log. | | `-sink-id` | `sink:dummy-sink` | Logical sink identifier (used in verify + handover attestation). | | `-actor` | `agent-dummy-sink` | Agent registry id of the dummy-sink identity. | | `-signing-key-ref` | `trust-api/agent-dummy-sink-2026-q4` | `key_ref` the handover attestation is signed with. | | `-pocket-id-issuer` (env `POCKET_ID_ISSUER`) | *(required in prod)* | Pocket-ID OIDC issuer URL. | | `-pocket-id-client-id` (env `POCKET_ID_CLIENT_ID`) | *(required in prod)* | OIDC client id for this agent. | | `-pocket-id-client-secret` (env `POCKET_ID_CLIENT_SECRET`) | *(required in prod)* | OIDC client secret for this agent. | | `-pocket-id-audience` (env `POCKET_ID_AUDIENCE`) | `trust-api` | OIDC token audience. | | `-workload-token-path` (env `DUMMY_SINK_WORKLOAD_TOKEN_PATH`) | `/var/run/secrets/tokens/trust-api` | Projected SA token path. | | `-dev-pocket-id-token` | *(unset)* | **Dev/CI only.** Static Pocket-ID access token. | | `-dev-workload-token` | *(unset)* | **Dev/CI only.** Static projected SA token. | ## Handover attestation (`basicstack.de/attestations/handover/v1`) ```jsonc { "_type": "https://in-toto.io/Statement/v1", "subject": [ { "name": "handover/", "digest": { "sha256": "<64-hex payload digest>" } } ], "predicateType": "https://basicstack.de/attestations/handover/v1", "predicate": { "decision_id": "", "payload_hash": "sha256:<64-hex>", "approval_ref": "sha256:<64-hex>", "transform_ref": "sha256:<64-hex>", "sink_id": "sink:dummy-sink", "sink_record_ref": "file:///.json", "materials": [ { "name": "approval/", "attestation_ref": "" }, { "name": "transform/", "attestation_ref": "" } ], "actor": "agent-dummy-sink", "issued_at": "" } } ``` The subject digest is `sha256(payload)` so a verifier can detect any tampering between what the agent handed the sink and what the sink persisted. The two `materials[]` entries pin the attestation chain back to the (approval, transform) that authorised this handover. ## Tests ``` cd services/dummy-sink go vet ./... go test ./... ``` The HTTP tests spin up the dummy-sink against httptest-backed fakes for verify / sign / tlog and cover the DEV-611 acceptance criteria: - **Happy path.** `valid=true` → `201` + file on disk + signer called with the handover statement + TLog entry whose `content_hash` matches the returned `handover_ref`. - **Chain invalid.** `valid=false` → `403` with `error.reasons`, no file written, signer and TLog never called. - **Replay.** Second call with the same `decision_id` where the verifier returns `replay: true` → `409`, signer not re-called. - **Payload hash mismatch.** Client-sent `payload_hash` disagrees with both the raw and canonical hash → `400`, verifier never called. - **Bad request shapes.** Missing fields / malformed `sha256:` prefix → `400`. - **Verifier unavailable / signer denied.** → `502` / `403` with the appropriate code so operators can distinguish transport failures from policy denials. ## Known Phase-1 integration gap The testagent-transform CLI shipped in [DEV-610](/DEV/issues/DEV-610) still posts to `/api/v1/handover` with payload-in-body + `X-Attestation-Ref` header, whereas this sink implements the DEV-611 contract (`/api/v1/sink/dummy/deliver` with structured JSON). Aligning the agent is parent-plan Subtask 7 (`make phase1-smoke`); DEV-611 scope is sink-side only.