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.

  • Spec 0.1 · bundle format 1
  • Java 17+ · Node 20+
  • Pre-release · build from source
  • Apache-2.0
  • Checked Oct 11, 2026
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.

Git
Config repositorytierwise.yaml, schema, values and rules files
CI
validate · test · compileEngine checks, your examples and lint; then a canonical bundle
Artifact
bundle.jsonsha256:…The same sources give the same bytes
tierwise-serverOptional: publish, history, promote, rollback
Runtime
Java servicesIn process: core, Spring Boot starter, OpenFeature
NodeIn process: evaluator, loader, OpenFeature
BrowserDisplay only
Git is the only write path. The server is never the source of truth and never on the hot path; services keep answering from their last good bundle if it is down.

Tierwise 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, or ignore with 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.

StrategyTypesResultWhen nothing contributes
Commutative: no default allowed
allbooleanAND; one false vetoestrue
anybooleanORfalse
intersectsetThe universe ∩ every valueThe full universe
unionsetUnion of every value[]
minintegerThe smallestAbsent
maxintegerThe largestAbsent
Order-dependent: default or root value required
overrideanyThe most specific value winsThe key’s default
deep_mergeobjectFields merged recursively; lists replacedThe 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.

PresetKinds
tierwise:minimalNone; lint for likely bugs only
tierwise:recommendedconfiguration (override or deep_merge, default required) and capability (all, intersect or min; narrowing only)
tierwise:entitlementslimit (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
  1. 1BindThe most specific binding on the path replaces all others
  2. 2PrecheckAny fact the policy names is missing → if_fact_missing
  3. 3RulesTop to bottom; the first whose conditions all hold wins
  4. 4DefaultNo rule matched → the policy’s default
The precheck runs before any rule, so the answer doesn’t depend on rule order when facts are missing. The reason is RULE, DEFAULT or MISSING_FACT, and the trace lists each rule checked and its first failing condition.
ConditionApplies toHolds whenEmpty collection
fact: valueA single-value factEqual—
{ in: [...] }A single-value factOne of the list—
{ all_in: [...] }A child factEvery child’s value is in the listfalse
{ any_in: [...] }A child factSome child’s value is in the listfalse

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, false for all, [] for intersect, or the declared lower bound for min. 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.

PropertyDefaultMeaning
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-interval30s0 turns refresh off
tierwise.staleness-threshold5mAfter this, isStale() is true and failures log at ERROR
tierwise.provider-timeout200msPer 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.

CommandPurpose
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|tsTyped 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.

EndpointPurposeWho
POST /v1/bundlesPublish a bundle and its record; idempotent, and different bytes for an existing version are refusedpublish role
GET /v1/bundles/latestThe current bundle; ETag is its version, so loaders get 304sreads
GET /v1/bundles · /{version}History, newest first, or one versionreads
POST /v1/bundles/{version}/promoteMove latest, forward or backpromote role
POST /v1/resolve · /v1/decideEvaluate over HTTP for callers that can’t embed an evaluatorreads

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 FIRST hit 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 resolve 5.5 µs, decide 4.9 µs; Node resolve 4.4 µs, decide 7.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_MANIFEST on a fresh project
Often specVersion: 0.1, which YAML reads as a number. Quote it: specVersion: "0.1".
E_MISSING_DEFAULT
An override or deep_merge key has neither a default nor a root value. Add one.
E_MISSING_SOURCE_FAILURE
A key settable at a service-sourced level needs on_source_failure: skip or deny, on the key or its kind.
E_QUANTIFIER_REQUIRED
A child fact is tested with a plain value or in. Use all_in or any_in.
E_BUNDLE_INTEGRITY at 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.url or tierwise.snapshot, or add a BundleSource bean.

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.