requisite makes data-handling requirements visible in Kotlin types. It is a
small, dependency-free Kotlin Multiplatform library for JVM and Kotlin/JS.
import io.github.slepp.requisite.*
fun findCustomer(id: Tainted<Long, Trusted>): Customer = TODO()
val customer =
Tainted
.fromInput(request.queryParameters["customer"])
.trySanitize { raw -> runCatching { requireNotNull(raw).toLong() } }
.fold(onSuccess = ::findCustomer, onFailure = ::rejectRequest)The receiving function states the requirement. Callers must perform the named transition before the value has the required type.
| API | Purpose |
|---|---|
Tainted<T, Untrusted> / Tainted<T, Trusted> |
route input through a destination policy |
TrustedSink<T, R> |
store or pass a handler that only accepts trusted values |
Confident<T> / Gate<T> |
validate a probability and choose an exhaustive action tier |
Certain |
authorize high-confidence operations with a library-issued token |
Fresh<T> / Freshness<T> |
check a per-value monotonic TTL and preserve stale values for recovery |
The planned Maven Central coordinates for release 0.1.0 are:
dependencies {
implementation("io.github.slepp:requisite:0.1.0")
}JVM-only builds that do not use the Kotlin Multiplatform plugin can depend on
io.github.slepp:requisite-jvm:0.1.0.
The JVM artifact is Kotlin-first. Java can consume the emitted classes and the
trust SAM API, but there is no separate Java facade for Kotlin Result or
Duration signatures.
Kotlin/JS consumers use the multiplatform coordinates from Kotlin Gradle
source sets. requisite-js is a Kotlin/JS KLIB publication; this project does
not build an npm package or export a plain JavaScript/TypeScript API.
Until Central publication is configured, use mavenLocal() after:
./gradlew -PreleaseVersion=0.1.0 publishToMavenLocalAlternatively, depend on the project directly.
fun load(id: Tainted<Long, Trusted>): Customer = database.find(id.trustedValue())
val parsed =
Tainted
.fromInput(" 42 ")
.trySanitize { text -> runCatching { text.trim().toLong() } }
val customer = parsed.fold(onSuccess = ::load, onFailure = ::rejectRequest)sanitize handles an infallible policy. trySanitize uses Kotlin Result for
parsing or validation. Both policies may change the value type.
Use an ordinary function parameter for most sinks. TrustedSink is a SAM type
for a handler that must be stored or passed:
val lookup = TrustedSink<Long, Customer> { id -> database.find(id.trustedValue()) }
val customer = trustedId.sendTo(lookup)inspect() reads a value without changing its state. widen() explicitly
lowers Trusted to Untrusted; there is no upward cast. The library does not
offer a general trusted map: an arbitrary transform could mix new untrusted
data into a trusted representation.
These types protect ordinary, type-checked Kotlin and Java call sites. Kotlin unchecked casts, reflection, generated bytecode, deserialization tricks, or other deliberate type-system bypasses can forge generic state. Treat the wrappers as reviewable application contracts, not as a sandbox for hostile code.
TrustState is sealed for Kotlin exhaustiveness, but Java 11 bytecode cannot
encode JVM sealed-interface permits. Java source can therefore implement
another TrustState. It still cannot construct a trusted Tainted value
through ordinary APIs, but an exhaustive Kotlin when over an arbitrary
Java-supplied TrustState can encounter an unexpected implementation.
val forecast = Confident.of("strong aurora", 0.97).getOrThrow()
val outcome =
when (val gate = forecast.gate()) {
is Gate.HighConfidence -> wake(gate.proof, gate.value)
is Gate.Likely -> buzz(gate.value)
is Gate.Unsure -> record(gate.value)
}Confident.of accepts only finite probabilities in 0.0..1.0. Gate is
sealed, so when is exhaustive. Its high tier carries Certain; users cannot
construct that token or a gate tier directly.
The defaults are likely at 0.60 and certain at 0.95. Custom thresholds are
validated:
val thresholds = Thresholds.of(likely = 0.75, certain = 0.99).getOrThrow()
val gate = forecast.gateWith(thresholds)The certain threshold cannot be lower than 0.95, keeping the token's meaning
consistent across consumers.
Certain is intentionally a small capability, not a dependent proof. A token
means that some library gate met a threshold of at least 0.95; it is reusable
and is not bound to a value, model, request, time, or action. Keep the token and
gated value together, and introduce a domain-specific authorization type when
that stronger binding matters.
Constructors and internal issuers are hidden from ordinary Java source with
private constructors and @JvmSynthetic. Reflection, handcrafted bytecode, and
Java nullability violations remain outside the guarantee.
import kotlin.time.Duration.Companion.seconds
val quote = Fresh.fetch(price, 30.seconds)
when (val checked = quote.check()) {
is Freshness.Current -> charge(checked.value)
is Freshness.Stale -> {
audit(checked.staleValue.value)
val retried = fetchPrice()
}
is Freshness.Unavailable -> {
recordClockFailure(checked.error)
val retried = fetchPrice()
}
}Each Fresh value owns its fetch mark and TTL. Age uses Kotlin's monotonic
TimeSource abstraction rather than storing wall-clock timestamps.
Freshness.Stale retains T, its age, and its TTL for refresh, audit, or
fallback logic. It deliberately cannot reset its own TTL: recovery must perform
the application's real fetch.
check() is exhaustive for ordinary clock failures. A TimeMark exception
becomes Freshness.Unavailable with InvalidTimeMark; a negative elapsed
reading becomes InvalidFetchTime and fails closed. Positive infinite ages and
TTLs are handled without undefined infinity - infinity arithmetic. fold()
accepts current, stale, and unavailable handlers.
Fresh.fetchedAt accepts an existing TimeMark for caches and deterministic
tests, and rejects future or invalid marks.
Fresh.fetch throws InvalidTtl for a negative TTL and propagates an exception
from a custom TimeSource.markNow() because no usable Fresh value exists yet.
Once constructed, check() turns ordinary mark-reading exceptions into the
typed unavailable branch; fatal platform errors are not intercepted.
Kotlin/JVM uses the platform monotonic source. On Kotlin/JS, Kotlin uses
process.hrtime() on Node.js, performance.now() in browsers when available,
and falls back to Date.now(). That last fallback is wall-clock based: a
backward jump is detected and fails closed, while a forward jump can expire a
value early.
The Rust library's Live / with_live API is not ported. Kotlin cannot express
the higher-ranked lifetime that prevents a scoped value from escaping a
closure. A wrapper or contracts-based imitation would look safer than it is,
so this library offers no approximation.
Arrow was evaluated for Either and Raise interop. The core uses Kotlin
Result and sealed results instead: adding Arrow to every target would not
strengthen a type contract and would force a dependency on users who do not
otherwise need it. Arrow applications can adapt at their boundaries:
fun <T> Result<T>.toEither(): arrow.core.Either<Throwable, T> =
fold(
onSuccess = { arrow.core.Either.Right(it) },
onFailure = { arrow.core.Either.Left(it) },
)No Arrow types appear in the published API.
The build uses JDK 21. Kotlin and Java compilation target Java 11 with Java
--release 11; compiler warnings fail the build. Signing validation also
requires Bash and GnuPG.
./gradlew ktlintCheck allTests build checkKotlinAbi
./gradlew dokkaGeneratePublicationHtml publishAllPublicationsToBuildRepository
./gradlew :examples:payment-flow:run
scripts/verify-publications.sh
scripts/signing-smoke.sh
scripts/reproducibility-smoke.shRuntime tests run on JVM and Node.js. JVM tests also invoke the Kotlin compiler
and JDK javac against independent consumer modules. Positive controls must
compile; negative cases assert diagnostic categories for trust promotion,
private proof/tier construction, hidden issuers, confidence misuse, and
adversarial Java null-proof attempts.
Licensed under either
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.