Trivial Senken-Adapter per §7 of architecture V1.1. Accepts
{decision_id, payload_hash, payload, approval_ref, transform_ref}
on POST /api/v1/sink/dummy/deliver; calls verification-service's
/api/v1/verify/chain and branches:
- valid=true → atomic .tmp+rename write of payload to
/data/dummy-sink/<decision_id>.json, builds a
basicstack.de/attestations/handover/v1 statement, signs via
signing-service with the double identity flow (Pocket-ID bearer
+ projected SA token), submits DSSE envelope to the TLog and
returns 201 {handover_ref, log_index}.
- valid=false & replay=true → 409.
- valid=false → 403 {reasons:[…]}.
Replay protection lives in verification-service per the parent plan;
dummy-sink keeps no local replay store. The payload_hash field is
verified against both the raw and canonical form so a verifier can
reproduce either hash.
Tests use httptest-backed fakes for verify / sign / tlog and cover
the DEV-611 acceptance criteria plus the bad-request shapes:
- happy path writes the file, calls signer with the handover
statement, submits the envelope to the TLog
- 403 keeps the disk and never calls signer/tlog
- replay returns 409 without re-signing
- payload_hash mismatch rejects before verify
- malformed bodies return 400
- verify unavailable → 502, signing denied → 403
Co-Authored-By: Paperclip <noreply@paperclip.ing>
7.6 KiB
dummy-sink
Phase-1 Mock-Senke (sink:dummy-sink) per DEV-611,
parent 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
- Verify the submitted hash matches
payload. POST {VERIFY_URL}/api/v1/verify/chainwith{decision_id, payload_hash, approval_ref, transform_ref, sink_target}.- Branch on the verdict:
valid: true→- Write
payloadatomically to{DATA_DIR}/{decision_id}.json(.tmp+ rename). - Build the handover statement (predicateType
https://basicstack.de/attestations/handover/v1) that bindsdecision_id,payload_hash,approval_ref,transform_ref, andsink_record_refinto the trust chain. POST {SIGNING_URL}/api/v1/sign/dssewith the double identity headers (Pocket-ID bearer + projected SA token).POST {TLOG_URL}/api/v1/log/entrieswith the signed envelopecontent_hash.
201 Created { handover_ref, log_index, decision_id }.
- Write
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_idconsumption.
Configuration
| Flag / env | Default | Purpose |
|---|---|---|
-listen |
:8083 |
HTTP listen address. |
-data-dir |
/data/dummy-sink |
Where delivered payloads are written as <decision_id>.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)
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "handover/<decision_id>",
"digest": { "sha256": "<64-hex payload digest>" }
}
],
"predicateType": "https://basicstack.de/attestations/handover/v1",
"predicate": {
"decision_id": "<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://<DATA_DIR>/<decision_id>.json",
"materials": [
{ "name": "approval/<approval_ref>", "attestation_ref": "<approval_ref>" },
{ "name": "transform/<transform_ref>", "attestation_ref": "<transform_ref>" }
],
"actor": "agent-dummy-sink",
"issued_at": "<RFC3339 UTC>"
}
}
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 whosecontent_hashmatches the returnedhandover_ref. - Chain invalid.
valid=false→403witherror.reasons, no file written, signer and TLog never called. - Replay. Second call with the same
decision_idwhere the verifier returnsreplay: true→409, signer not re-called. - Payload hash mismatch. Client-sent
payload_hashdisagrees 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/403with 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 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.