Chio/Docs
LOGIN · JOIN

JVM and .NET HTTP

Run Larkfield's pricing service in Spring Boot and ASP.NET Core, with every quote admitted by the kernel before the controller runs.

Guide chio@5f63e6e

On this page

Many pricing systems run on the JVM or .NET. Each keeps its normal request pipeline here: a servlet filter in Spring Boot, middleware in ASP.NET Core, and Chio's sidecar beside both, deciding and signing before a quote is issued.

Starts from chio new pricing-jvm-dotnet --example pricing-jvm-dotnet. Both implementations serve POST /v1/quote and GET /v1/quotes on Larkfield's price table, computing prices with decimal arithmetic. The middleware architecture shows where the service and the sidecar meet.

Start

A governed entry point for an existing service

The service keeps its controllers, validation, and storage. The adapter in its pipeline asks the sidecar whether the caller's grant covers the request, and only an admitted request reaches the controller.

1. Start the Spring Boot service and its sidecar

You need Java 17 or newer for Spring Boot, or the .NET 8 SDK for ASP.NET Core. The launcher resolves world.chio:chio-spring-boot from Maven Central, builds the service, starts it on port 8103 beside a sidecar on 9102, and issues a five-minute grant to Basalt's buying agent. You can also download the project.

Terminal 1bash
chio new pricing-jvm-dotnet --example pricing-jvm-dotnet && cd pricing-jvm-dotnet
python3 run.py

2. Quote and list

Terminal 2 · pricing-jvm-dotnetbash
python3 run.py quote sku-118 10
python3 run.py list
Output
{
  "status": 201,
  "body": {
    "id": 1, "sku": "sku-118", "percent": 10.0, "price": "21.60", "currency": "USD",
    "receipt_id": "c3a9…5f12"
  },
  "headers": { "x-chio-receipt-id": "c3a9…5f12" }
}
{
  "status": 200,
  "body": { "quotes": [{ "id": 1, "sku": "sku-118", "percent": 10.0, "price": "21.60", "currency": "USD" }] },
  "headers": {}
}

You should see a 201 with USD 21.60 and the admission receipt's ID, then the recorded quote. After python3 run.py revoke the same quote returns 403 and records nothing.

Spring Boot: admission before the controller

Depend on the published starter

Read build.gradle.kts · complete 39 lines · complete file
build.gradle.kts · completekotlin
plugins {
    kotlin("jvm") version "2.3.0"
    kotlin("plugin.spring") version "2.3.0"
    id("org.springframework.boot") version "3.5.16"
}

group = "larkfield"
version = "0.1.0"

java {
    sourceCompatibility = JavaVersion.VERSION_17
}

repositories {
    mavenCentral()
}

dependencies {
    implementation(platform("org.springframework.boot:spring-boot-dependencies:3.5.16"))
    implementation("world.chio:chio-spring-boot:0.2.0")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")

    testImplementation(platform("org.springframework.boot:spring-boot-dependencies:3.5.16"))
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
    compilerOptions {
        freeCompilerArgs.add("-Xjsr305=strict")
        jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

Register the filter on the pricing routes

PricingApplication.kt · in PricingApplicationkotlin
    @Bean
    fun chioFilter() = FilterRegistrationBean<ChioFilter>().apply {
        filter = ChioFilter(ChioFilterConfig(
            sidecarUrl = System.getenv("CHIO_SIDECAR_URL") ?: "http://127.0.0.1:9102"
        ))
        addUrlPatterns("/v1/*")
        order = Ordered.HIGHEST_PRECEDENCE + 1
    }
Read PricingApplication.kt · complete 34 lines · complete file
PricingApplication.kt · completekotlin
package larkfield.pricing

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.core.Ordered
import world.chio.ChioFilter
import world.chio.ChioFilterConfig

@SpringBootApplication
class PricingApplication {
    @Bean
    fun bodyLimit() = FilterRegistrationBean<BodyLimit>().apply {
        filter = BodyLimit()
        addUrlPatterns("/v1/*")
        order = Ordered.HIGHEST_PRECEDENCE
    }

    @Bean
    fun chioFilter() = FilterRegistrationBean<ChioFilter>().apply {
        filter = ChioFilter(ChioFilterConfig(
            sidecarUrl = System.getenv("CHIO_SIDECAR_URL") ?: "http://127.0.0.1:9102"
        ))
        addUrlPatterns("/v1/*")
        order = Ordered.HIGHEST_PRECEDENCE + 1
    }
}

fun main(args: Array<String>) {
    runApplication<PricingApplication>(*args) {
        setDefaultProperties(mapOf("server.address" to "127.0.0.1", "server.port" to "8103"))
    }
}
Read QuoteController.kt · complete 80 lines · complete file
QuoteController.kt · completekotlin
package larkfield.pricing

import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import java.math.BigDecimal
import java.math.RoundingMode
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.nio.file.attribute.PosixFilePermissions

class InvalidQuote(message: String) : IllegalArgumentException(message)
data class Quote(val id: Int, val sku: String, val percent: Double, val price: String, val currency: String = "USD")

// Larkfield list prices, as main holds them after the Quickstart.
val prices = mapOf("sku-118" to BigDecimal("24.00"), "sku-204" to BigDecimal("58.00"), "sku-301" to BigDecimal("140.00"))

class QuoteStore {
    private val mapper = jacksonObjectMapper()
    private val path = Path.of(".pricing/spring-quotes.json")
    private var quotes: List<Quote>
    init {
        if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) {
            Files.createDirectories(path.parent, PosixFilePermissions.asFileAttribute(
                PosixFilePermissions.fromString("rwx------")
            ))
        } else { Files.createDirectories(path.parent) }
        quotes = if (Files.exists(path)) mapper.readValue(Files.readString(path)) else emptyList()
    }
    @Synchronized fun list(): List<Quote> = quotes.toList()
    @Synchronized fun issue(sku: String, percent: Double): Quote {
        val list = prices[sku] ?: throw InvalidQuote("unknown sku $sku")
        if (percent < 0 || percent > 100) throw InvalidQuote("percent must be between 0 and 100")
        val factor = BigDecimal.ONE - BigDecimal.valueOf(percent).divide(BigDecimal(100))
        val price = (list * factor).setScale(2, RoundingMode.HALF_UP).toPlainString()
        val quote = Quote(quotes.size + 1, sku, percent, price)
        val next = quotes + quote
        val temporary = Files.createTempFile(path.parent, "spring-quotes-", ".tmp")
        try {
            Files.writeString(temporary, mapper.writerWithDefaultPrettyPrinter().writeValueAsString(next))
            Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
        } finally { Files.deleteIfExists(temporary) }
        quotes = next
        return quote
    }
}

@RestController
class QuoteController {
    private val store = QuoteStore()
    @GetMapping("/healthz")
    fun health() = mapOf("status" to "ready", "framework" to "spring")
    @GetMapping("/v1/quotes")
    fun list() = mapOf("quotes" to store.list())
    @PostMapping("/v1/quote")
    fun quote(@RequestBody payload: JsonNode, response: HttpServletResponse): ResponseEntity<Any> {
        if (!payload.isObject || payload.size() != 2 || payload.get("sku")?.isTextual != true
            || payload.get("percent")?.isNumber != true) {
            throw InvalidQuote("body must contain exactly sku and percent")
        }
        val quote = store.issue(payload.get("sku").asText(), payload.get("percent").asDouble())
        return ResponseEntity.status(HttpStatus.CREATED).body(mapOf(
            "id" to quote.id, "sku" to quote.sku, "percent" to quote.percent,
            "price" to quote.price, "currency" to quote.currency,
            "receipt_id" to response.getHeader("X-Chio-Receipt-Id")
        ))
    }
    @ExceptionHandler(InvalidQuote::class)
    fun invalid(error: InvalidQuote) = ResponseEntity.badRequest().body(mapOf("error" to error.message))
}

A body-limit filter runs first and bounds known-length and chunked bodies to 8192 bytes before ChioFilter caches them. The controller checks the request, prices it with BigDecimal rounded half up, and records the quote in .pricing/spring-quotes.json.

ASP.NET Core: the same contract in C#

Stop the Spring Boot service, then select .NET.

Terminal 1bash
python3 run.py --framework dotnet
Program.cs · top levelcsharp
builder.Services.AddChioProtection(options => {
    options.MaxRequestBodyBytes = 8192;
    options.SidecarUrl = Environment.GetEnvironmentVariable("CHIO_SIDECAR_URL")
        ?? "http://127.0.0.1:9102";
});
var app = builder.Build();
var store = new QuoteStore(".pricing/dotnet-quotes.json");
app.UseWhen(context => context.Request.Path.StartsWithSegments("/v1"), branch => {
    branch.UseChioProtection();
});

Reference the published package

Read Pricing.csproj · complete 10 lines · complete file
Pricing.csproj · completexml
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Backbay.Chio.Middleware" Version="0.2.0" />
  </ItemGroup>
</Project>

The complete C# service

The middleware reads and hashes the complete body before admission, chunked requests included; the service sets MaxRequestBodyBytes and Kestrel's limit to 8192. The handler receives the rewound body after Chio admits it.

Read Program.cs · complete 76 lines · complete file
Program.cs · completecsharp
using Backbay.Chio;
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:8104");
builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = 8192);
builder.Services.AddChioProtection(options => {
    options.MaxRequestBodyBytes = 8192;
    options.SidecarUrl = Environment.GetEnvironmentVariable("CHIO_SIDECAR_URL")
        ?? "http://127.0.0.1:9102";
});
var app = builder.Build();
var store = new QuoteStore(".pricing/dotnet-quotes.json");
app.UseWhen(context => context.Request.Path.StartsWithSegments("/v1"), branch => {
    branch.UseChioProtection();
});
app.MapGet("/healthz", () => Results.Json(new { status = "ready", framework = "dotnet" }));
app.MapGet("/v1/quotes", () => Results.Json(new { quotes = store.List() }));
app.MapPost("/v1/quote", (JsonElement payload, HttpContext context) => {
    if (payload.ValueKind != JsonValueKind.Object || payload.EnumerateObject().Count() != 2
        || !payload.TryGetProperty("sku", out var sku) || sku.ValueKind != JsonValueKind.String
        || !payload.TryGetProperty("percent", out var percent) || percent.ValueKind != JsonValueKind.Number) {
        return Results.BadRequest(new { error = "body must contain exactly sku and percent" });
    }
    try {
        var quote = store.Issue(sku.GetString()!, percent.GetDecimal());
        return Results.Json(new {
            id = quote.Id, sku = quote.Sku, percent = quote.Percent, price = quote.Price, currency = "USD",
            receipt_id = context.Response.Headers["X-Chio-Receipt-Id"].ToString()
        }, statusCode: StatusCodes.Status201Created);
    } catch (ArgumentException error) {
        return Results.BadRequest(new { error = error.Message });
    }
});
app.Run();

record Quote(int Id, string Sku, decimal Percent, string Price);

sealed class QuoteStore {
    // Larkfield list prices, as main holds them after the Quickstart.
    static readonly Dictionary<string, decimal> Prices = new() {
        ["sku-118"] = 24.00m, ["sku-204"] = 58.00m, ["sku-301"] = 140.00m,
    };
    private readonly string path;
    private readonly object gate = new();
    private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web) { WriteIndented = true };
    private List<Quote> quotes;

    public QuoteStore(string path) {
        this.path = path;
        var directory = Path.GetDirectoryName(path)!;
        if (OperatingSystem.IsWindows()) Directory.CreateDirectory(directory);
        else Directory.CreateDirectory(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        quotes = File.Exists(path)
            ? JsonSerializer.Deserialize<List<Quote>>(File.ReadAllText(path), json)
                ?? throw new InvalidDataException("Quotes file must contain an array")
            : new();
    }
    public Quote[] List() { lock (gate) return quotes.ToArray(); }
    public Quote Issue(string sku, decimal percent) {
        if (!Prices.TryGetValue(sku, out var list)) throw new ArgumentException($"unknown sku {sku}");
        if (percent < 0 || percent > 100) throw new ArgumentException("percent must be between 0 and 100");
        var price = Math.Round(list * (1 - percent / 100), 2, MidpointRounding.AwayFromZero).ToString("0.00");
        lock (gate) {
            var quote = new Quote(quotes.Count + 1, sku, percent, price);
            var next = quotes.Append(quote).ToList();
            var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
            try {
                File.WriteAllText(temporary, JsonSerializer.Serialize(next, json));
                File.Move(temporary, path, overwrite: true);
            } finally { File.Delete(temporary); }
            quotes = next;
            return quote;
        }
    }
}
ASP.NET Core · POST /v1/quote with Basalt's grantjson
{
  "id": 1,
  "sku": "sku-118",
  "percent": 10,
  "price": "21.60",
  "currency": "USD",
  "receipt_id": "5e20…b8c3"
}

Know what the adapters govern

ConcernSpring BootASP.NET Core
Interception
Servlet filter mapped to /v1/*
UseWhen on /v1, then UseChioProtection
Body boundary
8192-byte reader before ChioFilter
8192-byte middleware and Kestrel limits; complete body hash
Receipt in the handler
Response X-Chio-Receipt-Id header
Response X-Chio-Receipt-Id header
Health route
/healthz outside the filter mapping
/healthz outside the middleware branch

The adapters ask the sidecar for a decision and verify the returned receipt; they never run the controller remotely. Authentication, validation, and persistence stay with the service.

Connect each response to its decision

The recorded quotes

The list in step 2 is the service's own record: one quote, issued under Basalt's grant.

A refused quote returns its receipt ID

Spring Boot · POST /v1/quote without a grantjson
{
  "error": "chio_access_denied",
  "message": "side-effect route requires a capability token",
  "receipt_id": "0d6e…a417"
}

An admitted quote returns the price

The 201 bodies above carry the price and the admission receipt's ID. A request for sku-999 with a valid grant is admitted and then rejected with 400; its receipt records the decision, not the controller's answer:

The sku-999 receipt · selected fieldsjson
{
  "verdict": { "verdict": "allow" },
  "response_status": 200,
  "metadata": { "chio_http_status_scope": "decision" }
}

Read your own receipts

Terminal 2 · pricing-jvm-dotnetbash
python3 receipts.py --decision deny
Output
0d6e…a417  DENY       POST   /v1/quote  HTTP 403

receipts.py opens .pricing/receipts.db read-only; pass a receipt ID in place of --decision to print that complete signed record, and post it to /chio/verify as HTTP receipt verification shows.

Resolve the failure you received

SituationSpring BootASP.NET CoreWhat to do
No grant
403
403
Issue a grant; nothing was recorded.
Grant, known SKU
201
201
Read the quote list and the receipt ID.
Grant, sku-999 or percent outside 0 to 100
400
400
Fix the request; admission allowed it and the controller refused it.
Body over 8192 bytes
413
413
Send a smaller body; no receipt was created.
Sidecar down
502
502
Restart the sidecar. /healthz stays 200 and recorded quotes survive.

Keep the runtime and the authority separate

The JVM and .NET adapters reach the sidecar over HTTP, so interception stays in the managed runtime and Chio's Rust process owns evaluation and signing. Protect the sidecar's operator interface and include the request boundary in the deployment design. The JSON quote files are deliberately small, single-process stores; move the controller onto your own database and transaction model.

Check the whole contract in one run

With the launcher stopped, python3 run.py check refuses a quote with no grant, issues one with Basalt's grant, rejects sku-999 after admission, revokes the grant, refuses again, and confirms the refused requests recorded nothing. Add --framework dotnet for ASP.NET Core.

Require a grant to read quotes

Mark the read operation side-effecting in openapi.yaml, restart the launcher with the same .pricing state, and read with and without the grant.

openapi.yaml · the GET operationyaml
paths:
  /v1/quotes:
    get:
      operationId: listQuotes
      x-chio-side-effects: true
      responses:
        "200": { description: Up to 100 issued quotes }
  # Keep the existing /v1/quote post operation here.
Terminal 2 · pricing-jvm-dotnetbash
curl -sS -w ' HTTP %{http_code}\n' http://127.0.0.1:8103/v1/quotes
curl -sS http://127.0.0.1:8103/v1/quotes \
  -H "X-Chio-Capability: $(jq -c . .pricing/capability.json)"
Output
{"error":"chio_access_denied","message":"side-effect route requires a capability token","receipt_id":"8f42…03bd"} HTTP 403
{"quotes":[{"id":1,"sku":"sku-118","percent":10.0,"price":"21.60","currency":"USD"}]}

The read without a grant is refused, and the read with Basalt's grant returns the same quote. Only the matched operation changes; unmatched safe methods keep the method-based default.

Next: change what the service permits