← All docs

Signet — Backend Service Design (Spring Boot, v0.2)

The registry service (server/, Java 21 + Spring Boot 3): module structure, request lifecycle, background workers, configuration, error model, observability, and testing strategy. v0.2 note: the backend is Spring Boot; the CLI and its verification library are Rust (single static binary for CI distribution). Parity between the two crypto implementations is enforced by the shared conformance vectors in [conformance/](../../conformance/) — see §9. Related: architecture.md, database.md, api-reference.md

1. Stack

ConcernChoiceRationale
RuntimeJava 21 (Temurin), Spring Boot 4.x, Spring MVC on virtual threadsthread-per-request simplicity with Loom-scale concurrency — meets NFR-2 bulk-read concurrency without reactive complexity. *Currently disabled:* JDK 21 vthread pinning stalled Tomcat request processing under load-free tests; re-enable when the runtime moves to JDK 24+ (pinning fix).
JSON boundaryBoot 4's web converters are Jackson 3 (tools.jackson); the bundle pipeline is Jackson 2 (com.fasterxml, required by the schema validator). Controllers therefore pass raw JSON strings across the HTTP boundary and parse with the pipeline's mapper.one JSON tree per concern; no cross-tree type leakage
Data access**JdbcClient + hand-written SQL** (no JPA)the hot path is a handful of index-only queries (database.md §4); explicit SQL keeps them reviewable and plan-predictable. JPA adds nothing here and invites accidental N+1 / enum-mapping surprises
MigrationsFlyway (server/src/main/resources/db/migration/V*.sql)Spring-native, forward-only in production
CryptoJDK Ed25519 (native since 15), sigstore-java for Fulcio/Rekor at M1, erdtman/java-json-canonicalization (RFC 8785), networknt/json-schema-validator (draft 2020-12)all first-party or de-facto standard; no bespoke crypto
ErrorsSpring ProblemDetailRFC 9457 native (§6)
Rate limitingin-process token bucket (common/TokenBucketRateLimiter; shared backend if multi-instance) — coarse per-IP + tier-aware per-identity, post-verification; toggle signet.ratelimit.enabledNFR-4
ObservabilityMicrometer → Prometheus, OTel tracing§7

2. Module Structure

Single Gradle/Maven project, packages by feature (Spring Modulith-style boundaries, enforced by ArchUnit tests):

dev.signet.server
├── record/        submission pipeline: controller → RecordService → RecordRepository
│                  + extract/ (bundle → derived rows)
├── coverage/      read path: controller → CoverageRepository (no service layer — it's one query)
├── identity/      signature → identity resolution, tiers, Fulcio cert verification (M1)
├── trust/         beta engine, EigenTrust batch, coverage writer, params config
├── ingest/        OSV/GHSA ground-truth poller
├── tlog/          Rekor client (via sigstore-java)
├── dump/          daily export writer + manifest signing
├── bundle/        DSSE envelope, PAE, JCS content-addressed IDs, statement
│                  validation — the Java twin of the Rust signet-bundle crate (§9)
└── common/        ProblemDetail types, rate limiting, config records

Rules mirror the old design: controllers stay thin (decode → service → encode); nothing outside trust/ computes scores; everything trust/ reads must be reproducible from (records, params, seeds) — the mirror-determinism invariant (database.md §6).

3. Request Lifecycle

Write: POST /v1/records

decode bundle → verify signatures (bundle/ + identity/)
  → tier + rate-limit check (Bucket4j, keyed by proven identity — 429 + tier info)
  → structural validation (statement rules + predicate schema)
  → DB-dependent checks (referenced record exists; not self-verification)
  → tlog submission (inclusion proof attached)
  → one @Transactional block: insert records + derived rows
  → publish RecordAccepted (Spring application event → jobs table)
  → 201 {id}

Synchronous through the transaction (writes may take hundreds of ms — NFR-2 binds reads only). Rate limiting binds to the *proven* identity after signature verification; a cheap per-IP bucket sits in front only to shed unverifiable garbage before crypto work.

Read: GET /v1/coverage

parse params → one PK-prefix JdbcClient query → filter minConfidence → encode

No auth, no joins, no trust computation at read time. Handler-internal target p99 < 20 ms; guarded by an SLO alert, not a preemptive cache (architecture §2). Virtual threads make the bulk endpoint (5,000-purl SBOM checks) plain blocking code.

There is no auth on reads at all, and no API keys anywhere: write authentication *is* the bundle signature. Spring Security is configured to exactly that — permit-all on GET, no session, no CSRF surface (the API is non-browser; the portal has its own Auth.js sessions).

4. Background Workers

WorkerTriggerJob
trust.evidencejobs table (fed by RecordAccepted / dispute events)beta-evidence updates (E), verifier slashing propagation
trust.coveragejobs tablerecompute affected coverage rows
trust.graph@Scheduled nightlyEigenTrust (G) over the endorsement graph
trust.decay@Scheduled nightlytime-decay pass over E scores
ingest.osv@Scheduled hourlyadvisories → falsification candidates into the dispute queue (never auto-slash)
dump.daily@Scheduled dailyJSONL partitions + signed manifest to object storage

Workers poll a Postgres jobs table with SELECT … FOR UPDATE SKIP LOCKED — no external queue at launch scale, crash-safe resume, and horizontal scaling later is just more instances on the same table. @Scheduled jobs take a Postgres advisory lock so multi-instance deployments run each batch once. Every worker is idempotent (event causes recorded in reputation_events.cause_ref; reprocessing is a no-op).

5. Configuration

@ConfigurationProperties(prefix = "signet") record, env-overridable (12-factor), validated at startup:

Property / envDefault
SIGNET_ADDRserver.port8080
SIGNET_DB_URLspring.datasource.urlrequired from M1
signet.tlog-urlpublic Rekortransparency log
signet.trust-paramsembedded defaultssigned params config path
signet.seedsembedded defaultsEigenTrust seed manifest
signet.dump.dirempty (disabled)local directory sink for dumps
signet.dump.bucket + signet.dump.s3-endpointempty (disabled)S3-compatible object-storage sink for dumps
signet.dump.s3-region / -prefix / -access-key / -secret-keyus-east-1 / —bucket sink credentials + layout
signet.read-onlyfalsemirror mode: writes → 403

Dump sinks are additive: set signet.dump.dir for a local mirror pull-point, signet.dump.bucket+s3-endpoint for offsite object storage (AWS S3, MinIO, Cloudflare R2, Backblaze B2 — any S3-compatible endpoint), or both. The partition and signed manifest are written to every configured sink each run. The S3 sink is all-or-nothing — bucket, s3-endpoint, s3-access-key, and s3-secret-key must be set together; a partial config fails fast at startup so a missing offsite backup is never a silent surprise.

signet.read-only=true is the entire difference between the hosted instance and a read mirror — same image, same config surface (architecture §8).

6. Error Model

RFC 9457 via Spring's ProblemDetail on every non-2xx (one @RestControllerAdvice):

{
  "type": "https://signet.dev/errors/validation-failed",
  "title": "statement validation failed",
  "status": 422,
  "detail": "subject[1] purl version must equal the commit SHA",
  "requestId": "..."
}

Stable type URIs are API contract (tools branch on them). Canonical set: validation-failed (422), signature-invalid (401), rate-limited (429 + Retry-After), not-found (404), duplicate-record (409 — body carries the existing ID; clients treat as success), read-only-mirror (403).

7. Observability

  • SLO: coverage read p99 < 100 ms external / < 20 ms handler-internal;

read error rate < 0.1%. Burn-rate alerts on both.

  • Micrometer RED metrics per route (coverage gets its own histogram — it

carries the SLO); jobs-table depth and lag per worker; trust-cycle duration; dump age (also public via /v1/dumps/manifest).

  • OTel tracing, sampled; the write pipeline is one trace (verify → tlog →

tx → event).

  • /healthz = process up (plain controller, matches the API contract);

/readyz = DB reachable + Flyway current (actuator-backed).

8. Testing Strategy

LayerApproach
bundle/unit tests against the shared conformance vectors (§9) plus the same rejection tables as the Rust suite
extract/, trust/golden-file tests (fixture bundle in → expected rows/scores out); trust math property-tested (score monotonicity, slash ≫ single reward)
repositories@JdbcTest against real Postgres via Testcontainers
HTTP API@SpringBootTest + MockMvc per controller; one end-to-end suite (submit → coverage appears) against docker-compose in CI
architectureArchUnit: package dependency rules from §2
determinismCI job: ingest a fixture dump into two fresh DBs → byte-identical scores and coverage

9. Two Crypto Implementations, One Behavior

The Rust CLI (crates/signet-bundle) and the Java server (bundle/ package) both implement DSSE + PAE + JCS IDs + statement validation. Divergence here is a correctness bug of the worst kind (a record the CLI signs that the server rejects — or worse, the reverse). Controls:

  1. Conformance vectors (conformance/vectors.json): PAE encodings,

envelope → expected record ID, and statement-validation cases with expected verdicts. Both test suites consume the same file; adding a validation rule means adding a vector first.

  1. The JSON Schemas are shared bytes (schemas/, embedded into both

builds) — predicate validation can't drift by construction.

  1. Canonicalization is delegated to RFC 8785 libraries on both sides, never

hand-rolled.

10. Security Notes

  • All input crosses bundle/ validation before touching the DB; there is

no unauthenticated write path of any kind.

  • The service holds no signing keys except the dump-manifest key (KMS

in production); it verifies everything and signs nothing else.

  • SQL exclusively through JdbcClient named parameters — no string-built

SQL anywhere (enforced in review + an ArchUnit rule banning Statement).

  • Actuator endpoints are bound to the management port, not exposed publicly

(/healthz and /readyz are deliberate public exceptions).