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
| Concern | Choice | Rationale |
|---|---|---|
| Runtime | Java 21 (Temurin), Spring Boot 4.x, Spring MVC on virtual threads | thread-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 boundary | Boot 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 |
| Migrations | Flyway (server/src/main/resources/db/migration/V*.sql) | Spring-native, forward-only in production |
| Crypto | JDK 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 |
| Errors | Spring ProblemDetail | RFC 9457 native (§6) |
| Rate limiting | in-process token bucket (common/TokenBucketRateLimiter; shared backend if multi-instance) — coarse per-IP + tier-aware per-identity, post-verification; toggle signet.ratelimit.enabled | NFR-4 |
| Observability | Micrometer → 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 recordsRules 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 → encodeNo 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
| Worker | Trigger | Job |
|---|---|---|
trust.evidence | jobs table (fed by RecordAccepted / dispute events) | beta-evidence updates (E), verifier slashing propagation |
trust.coverage | jobs table | recompute affected coverage rows |
trust.graph | @Scheduled nightly | EigenTrust (G) over the endorsement graph |
trust.decay | @Scheduled nightly | time-decay pass over E scores |
ingest.osv | @Scheduled hourly | advisories → falsification candidates into the dispute queue (never auto-slash) |
dump.daily | @Scheduled daily | JSONL 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 / env | Default | |
|---|---|---|
SIGNET_ADDR → server.port | 8080 | |
SIGNET_DB_URL → spring.datasource.url | — | required from M1 |
signet.tlog-url | public Rekor | transparency log |
signet.trust-params | embedded defaults | signed params config path |
signet.seeds | embedded defaults | EigenTrust seed manifest |
signet.dump.dir | empty (disabled) | local directory sink for dumps |
signet.dump.bucket + signet.dump.s3-endpoint | empty (disabled) | S3-compatible object-storage sink for dumps |
signet.dump.s3-region / -prefix / -access-key / -secret-key | us-east-1 / — | bucket sink credentials + layout |
signet.read-only | false | mirror 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
| Layer | Approach |
|---|---|
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 |
| architecture | ArchUnit: package dependency rules from §2 |
| determinism | CI 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:
- 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.
- The JSON Schemas are shared bytes (
schemas/, embedded into both
builds) — predicate validation can't drift by construction.
- 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
JdbcClientnamed 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).