Documentation · Tierwise
Tierwise docs.
A type checker and evaluator for layered business configuration. Declare your levels, keys and decisions once; get the same answer, with its provenance, in Java and JavaScript.
git clone https://github.com/djkeshawa/visp-tierwise
How it works
Applications combine values from several levels: platform → tenant → region → store → user, or plan → org → workspace → user. Without a shared schema, different readers get different answers.
Misspelled names silently remove restrictions, rules over collections leave “all” versus “any” unclear, and nobody can say where a value came from. Tierwise gives every key, fact and rule exactly one meaning, defined once and enforced at build time, so mistakes fail CI instead of production.
tierwise.yaml, schema, values and rules filessha256:…The same sources give the same bytesTierwise sits between “a folder of YAML” and a policy engine: stricter than the first, much narrower than the second. It has no expression language and no plugins.
Levels & scopes
A project is a directory with a tierwise.yaml manifest. You name the levels; Tierwise has no built-in ones.
decision_hierarchy:
levels: [global, channel, division, site, account, user]
axes: [seller]
scopes:
channel: { nodes: [web, partner_portal], on_unknown_node: error }
division: { nodes: [north, south, west], on_unknown_node: error }
site: { nodes: { from: "catalogs/sites.json" }, on_unknown_node: ignore }
account: { nodes: open, values: service }
user: { nodes: open, values: service }
seller: { nodes: [acme], on_unknown_node: error }
- Levels
- Ordered from least to most specific. The first is the root: one implicit node, always in effect, never named in a context.
- Axes
- Scopes that cut across levels with no order, such as a seller. Order-dependent keys must say how the levels and axes combine.
- Admission
- Each scope lists its nodes inline, from a generated catalog, or as
open. A closed list says what happens to an unknown node:error, orignorewith the scope recorded as skipped. - Context
- At most one node per level or axis, for example
{levels: {division: south}, axes: {seller: acme}}. Tierwise never infers a parent from a child.
Source files are always strict: a node that isn’t listed is E_UNKNOWN_NODE at build time. Node IDs are compared by exact code point, and sources are normalised to NFC.
Keys & merge rules
A key has a name, a type, a merge rule (combine) and the scopes where it may be set.
checkout.order.allowed_max_lines:
kind: capability
type: integer
min: 1
combine: min
settable_at: [global, division, site]
description: "The largest number of lines an order may have. The smallest bound wins."
checkout.pickup.lead_hours:
kind: configuration
type: integer
min: 0
max: 168
combine: override
default: 4
settable_at: [global, division, site, seller]
combine_across_axes: max
Types are boolean, integer, decimal, string, enum, set, list and object. There is no null, no coercion and no float. A value at a scope not in settable_at is E_NOT_SETTABLE_HERE.
| Strategy | Types | Result | When nothing contributes |
|---|---|---|---|
| Commutative: no default allowed | |||
all | boolean | AND; one false vetoes | true |
any | boolean | OR | false |
intersect | set | The universe ∩ every value | The full universe |
union | set | Union of every value | [] |
min | integer | The smallest | Absent |
max | integer | The largest | Absent |
| Order-dependent: default or root value required | |||
override | any | The most specific value wins | The key’s default |
deep_merge | object | Fields merged recursively; lists replaced | The key’s default |
With axes, order-dependent keys need combine_across_axes: hierarchy_wins, axis_wins, or max / min for integers.
Kinds and presets
A kind is a named set of constraints a key opts into: allowed strategies, narrowing only, a required default, a failure mode, naming. A preset bundles kinds, naming and lint, and a project extends one or more. Presets can tighten checks, never turn off an engine guarantee.
| Preset | Kinds |
|---|---|
tierwise:minimal | None; lint for likely bugs only |
tierwise:recommended | configuration (override or deep_merge, default required) and capability (all, intersect or min; narrowing only) |
tierwise:entitlements | limit (min), feature (all) and grant (union) |
Decisions
A feature is a decision table about an entity. Policies hold ordered rules, bindings choose a policy per scope, and examples are tests that run on every validate.
feature: checkout.order_submission
policies:
credit_check:
settings:
allowed_on_credit_hold: { list_of: item.stock_type, default: [] }
default: DENY
rules:
- id: good-credit
name: Good credit
when:
customer.active: true
customer.credit_hold: false
then: ALLOW
- id: credit-hold-allowed-stock
name: On credit hold, every item is an allowed stock type
when:
customer.active: true
customer.credit_hold: true
item.stock_type: { all_in: $allowed_on_credit_hold }
then: ALLOW
allow_all:
default: ALLOW
rules: []
bindings:
global: { policy: credit_check }
division:
north: { policy: allow_all }
south: { policy: credit_check, allowed_on_credit_hold: [STOCKED, SPECIAL_ORDER] }
examples:
- name: Mixed basket on credit hold is denied
context: { levels: { division: south } }
facts: { customer.active: true, customer.credit_hold: true, item.stock_type: [STOCKED, DROP_SHIP] }
expect: DENY
- 1BindThe most specific binding on the path replaces all others
- 2PrecheckAny fact the policy names is missing →
if_fact_missing - 3RulesTop to bottom; the first whose conditions all hold wins
- 4DefaultNo rule matched → the policy’s default
RULE, DEFAULT or MISSING_FACT, and the trace lists each rule checked and its first failing condition.| Condition | Applies to | Holds when | Empty collection |
|---|---|---|---|
fact: value | A single-value fact | Equal | — |
{ in: [...] } | A single-value fact | One of the list | — |
{ all_in: [...] } | A child fact | Every child’s value is in the list | false |
{ any_in: [...] } | A child fact | Some child’s value is in the list | false |
A child fact without a quantifier is E_QUANTIFIER_REQUIRED; a single-value fact with one is E_QUANTIFIER_NOT_ALLOWED. Rules and settings are never merged across bindings. In 0.1, hit_policy is FIRST and facts go one level of children deep.
Providers
A level marked values: service gets its values at run time from your code: a provider receives a node ID and returns a key-to-value map. Every key settable there must declare on_source_failure:
skip: the scope contributes nothing.deny: it contributes the most restrictive value,falseforall,[]forintersect, or the declared lower bound formin. Only narrowing strategies may deny.
A provider is called at most once per scope per evaluation, with a required timeout. A throw or timeout is recorded as UNAVAILABLE and an invalid value as INVALID, and the result is still returned. decide never calls a provider.
User guide
Nothing is published yet, so every path starts from the repository. Each quickstart takes under five minutes.
1. Build the CLI and start a project
The build needs a full JDK 21 (with javac and ct.sym); the CLI then runs on Java 17 or newer.
git clone https://github.com/djkeshawa/visp-tierwise
cd visp-tierwise
(cd java && ./mvnw -B -ntp -DskipTests package)
alias tierwise="java -jar $(ls "$PWD"/java/tierwise-cli/target/tierwise-cli-*-all.jar)"
tierwise init --preset=entitlements my-config
tierwise validate my-config
Misspell a key in my-config/tierwise.yaml and validate again:
tierwise.yaml:57: error E_UNKNOWN_KEY the key quota.max_itmes is not declared (KEY-10, SRC-35) (suggestion: quota.max_items)
validate: 1 error, 0 warnings, 0 infos
2. Java and Spring Boot
Install the modules into your local Maven repository with cd java && ./mvnw -B -ntp -DskipTests install, then depend on io.github.tierwise:tierwise-core. The core needs Java 17 and has two runtime dependencies.
Evaluator evaluator = Tierwise.evaluator(Tierwise.loadBundle(bytes), EvaluatorOptions.none());
Context context = Context.builder().level("division", "south").build();
Resolved resolved = evaluator.resolve(context, "checkout.order.allowed_max_lines");
resolved.value(); // the answer
resolved.contributions(); // each scope, its value and its effect
FactSet facts = FactSet.builder()
.fact("customer.active", true)
.fact("customer.credit_hold", true)
.child("item", Map.of("item.stock_type", "STOCKED"))
.build();
Decision decision = evaluator.decide(context, "checkout.order_submission", facts);
decision.effect(); // ALLOW
decision.trace(); // each rule checked
The evaluator is immutable and thread-safe. tierwise codegen --lang=java generates typed handles such as Keys.CHECKOUT_PICKUP_LEAD_HOURS.
The Spring Boot starter (Java 21, Spring Boot 4) provides Resolver, DecisionEngine and TierwiseEvaluators beans, refreshes the bundle by ETag, swaps it atomically and keeps the last good one when a refresh fails.
| Property | Default | Meaning |
|---|---|---|
tierwise.source | — | classpath:, file:, http(s):// or s3://bucket/key (with the S3 module) |
tierwise.server.url | — | Load from tierwise-server instead; set this or source |
tierwise.snapshot | — | A start-up fallback; without one, start-up fails fast if the bundle can’t load |
tierwise.refresh-interval | 30s | 0 turns refresh off |
tierwise.staleness-threshold | 5m | After this, isStale() is true and failures log at ERROR |
tierwise.provider-timeout | 200ms | Per level with tierwise.provider-timeouts.<level> |
Micrometer meters: tierwise.bundle.version, tierwise.bundle.age, tierwise.refresh.failures, tierwise.decisions (by feature and reason) and tierwise.provider.failures. Decision log lines never include context or facts.
3. JavaScript
@tierwise/evaluator runs on Node 20+ and in browsers with no runtime dependencies. Build the packages with pnpm --dir js install --frozen-lockfile && pnpm --dir js run build, then install from the checkout with npm install "file:/path/to/visp-tierwise/js/evaluator".
import { readFileSync } from 'node:fs';
import { createEvaluator, loadBundle } from '@tierwise/evaluator';
const bundle = loadBundle(readFileSync(process.argv[2]));
const evaluator = createEvaluator(bundle, {});
for (const group of ['group_a', 'group_b']) {
const resolved = await evaluator.resolve({ levels: { group } }, 'quota.max_items');
console.log(`${group} quota.max_items = ${resolved.value}`);
for (const c of resolved.contributions) {
const scope = c.name + (c.node === undefined ? '' : `=${c.node}`);
console.log(` ${scope}: ${c.value} ${c.effect}`);
}
}
resolveSync and decideSync exist for bundles that need no provider. @tierwise/node adds a loader with ETag refresh, an atomic swap, last-known-good and loader.status().
CLI
One command, seven subcommands. Findings come out in a deterministic order, as text or JSON.
| Command | Purpose |
|---|---|
tierwise init [--preset=…] [DIR] | A minimal, valid project from recommended, minimal or entitlements; never overwrites |
tierwise validate [--format=json] [DIR] | Engine checks, examples and lint; exit 1 on any error |
tierwise lint [DIR] | Lint only; runs when there are no engine findings |
tierwise test [DIR] | Validate, then report each example as passed or failed |
tierwise compile [--provenance] [-o FILE] [DIR] | Write the canonical bundle; --provenance adds a sidecar with createdAt and gitCommit |
tierwise explain [CODE|RULE] | Explain an error code or lint rule, with a minimal example and the fix |
tierwise codegen --lang=java|ts | Typed key and feature handles for Java or TypeScript |
Exit codes: 0 success; 1 an error finding, a failed example or blocked lint; 2 a usage or I/O error. A CI job usually runs:
java -jar tierwise.jar validate --format json > validate.json # annotations
java -jar tierwise.jar test
java -jar tierwise.jar compile --provenance -o bundle.json
The repository also has a Docker image definition, a GitHub Action (action.yml) and a GraalVM native-image profile. They are built and tested in CI, but not yet published.
Server & OpenFeature
tierwise-server is an optional, stateless control plane: it stores published bundles and moves a latest pointer. It is never the source of truth.
| Endpoint | Purpose | Who |
|---|---|---|
POST /v1/bundles | Publish a bundle and its record; idempotent, and different bytes for an existing version are refused | publish role |
GET /v1/bundles/latest | The current bundle; ETag is its version, so loaders get 304s | reads |
GET /v1/bundles · /{version} | History, newest first, or one version | reads |
POST /v1/bundles/{version}/promote | Move latest, forward or back | promote role |
POST /v1/resolve · /v1/decide | Evaluate over HTTP for callers that can’t embed an evaluator | reads |
Authentication is OAuth2 JWT with roles from a claim (tierwise-publisher, tierwise-promoter), and there are no default credentials. Storage is the filesystem by default, or S3 with the S3 module. The latest pointer moves only on first publication, so a rerun CI job can’t undo a rollback.
OpenFeature: Java and JavaScript providers map a flag key to a Tierwise key or feature. Levels, axes and facts travel in the context as tierwise.levels, tierwise.axes and tierwise.facts, and the provenance comes back as flag metadata. Wrong types are TYPE_MISMATCH, never converted. Tierwise is not a flag tool: it has no rollouts or experiments.
Security & limits
Three things to remember.
- Browsers hint, servers decide
- A decision made in a browser can be changed by whoever controls the browser.
- Publishing is the privileged act
- Whoever can publish a bundle controls every answer. Protect the publish role and your CI.
- The digest detects damage, not forgery
- A bundle’s SHA-256 catches corruption, but anyone can recompute it. There is no bundle signing in 0.1.
- Provenance is internal
- Don’t return a full result to untrusted callers: contributions reveal your configuration. Tierwise can’t tell when a caller passes the wrong tenant.
Known limits in 0.1
- Only the
FIRSThit policy; no numeric comparisons; rules can’t read resolved keys. - Facts go one level of children deep, with no nested quantifiers.
- No server history pagination.
- 218 specification clauses are covered by enforcer tests rather than conformance vectors.
- Benchmarks use a plain harness, not JMH. Measured p99: Java
resolve5.5 µs,decide4.9 µs; Noderesolve4.4 µs,decide7.0 µs, on one laptop.
Release status
The repository is public and the engineering for 0.1.0 is complete. The release itself isn’t done.
Done
- Hosted CI green; 3,324 Java and 1,233 JavaScript tests
- Nightly differential run: 10,000 cases, no difference
- Release dry run built native binaries, Docker images and SBOMs
- Quickstarts pass literally on a fresh runner
Still to do
- Register the Maven Central namespace and npm scope
- Signing keys, then the real release run
- The first scheduled nightly run
- Publish the docs site
Until then, packages are versioned 0.1.0-dev.0 and marked private. The project may later move to a tierwise GitHub organization; GitHub will redirect the current URL.
When something is stuck
E_MANIFESTon a fresh project- Often
specVersion: 0.1, which YAML reads as a number. Quote it:specVersion: "0.1". E_MISSING_DEFAULT- An
overrideordeep_mergekey has neither adefaultnor a root value. Add one. E_MISSING_SOURCE_FAILURE- A key settable at a service-sourced level needs
on_source_failure: skipordeny, on the key or its kind. E_QUANTIFIER_REQUIRED- A child fact is tested with a plain value or
in. Useall_inorany_in. E_BUNDLE_INTEGRITYat load- The bytes changed after compiling, often a proxy rewriting the body or a hand edit. Serve the compiler’s output unchanged.
E_PROVIDER_REQUIRED- A sync call would need a provider. Use the async
resolve. - No Tierwise bundle source is configured
- Set
tierwise.source,tierwise.server.urlortierwise.snapshot, or add aBundleSourcebean.
Any code or lint rule: tierwise explain E_UNKNOWN_KEY. Checked against the repository on October 11, 2026.
Full reference: modeling guide, CLI reference, runtime and operations, server and specification.
