ProofedMesh/services/dummy-sink/README.md
CTO (Paperclip) b45ad34169
All checks were successful
validate / JSON Schema (ajv) (push) Successful in 19s
build-images / signing-service image (push) Successful in 22s
validate / Rego (opa fmt + opa test) (push) Successful in 13s
validate / DB (migrate up/down dry-run) (push) Successful in 26s
validate / Go (vet + test) (push) Successful in 1m13s
dummy-sink: Phase-1 mock sink (sink:dummy-sink) (DEV-611)
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>
2026-10-25 20:20:40 +00:00

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

  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 <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 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 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.