Tier Transition Mechanism — Dual-Surface `transition_tier` with Transactional Effects and Witnessed Substrate Dormancy

Accepted

ontoref
ADR-029 (Tier Coexistence as Permanent Design) declared that the protocol

Context

ADR-029 (Tier Coexistence as Permanent Design) declared that the protocol admits tier-0, tier-1, and tier-2 simultaneously and indefinitely, with per-project tier choice recorded in `.ontoref/config.ncl::ops.tier`. Five Hard constraints anchor the property:

- `no-forced-tier-migration` — silent/automatic tier change forbidden - `tier-0-must-resolve-schemas` — lower-tier operability preserved - `offline-first-default-stack` — no network requirement at any tier - `additive-stack-no-breaking-tier-changes` — no API removal across tiers - `tier-transition-requires-clean-migration-state` — guard against transition with pending migrations

The fifth constraint's enforcement primitive — `enforce_tier_transition_guard(old, new, pending_migrations)` — is implemented in `crates/ontoref-daemon/src/tier_guard.rs` (175 LOC, 8 passing unit tests). It is a pure function that returns `Ok(())` or `Err(TierGuardError::MigrationsPending)`. It is **not yet wired into any caller** because no caller exists: ADR-029 committed the constraint at the protocol-semantic level, deferring the operational materialisation to a follow-up ADR.

This ADR is that follow-up. It defines the operational primitive that moves a project between tiers — what it is called, where it lives, what it does, and what guarantees it offers — without re-opening ADR-029's already-settled questions (the three tiers are permanent; transition is voluntary; migrations-clean is a precondition).

CURRENT STATE OF THE SYSTEM ENTERING THIS DECISION

- ontoref-el-proyecto (the piloto) runs at tier-2 / Phase Progressive *empirically* — 10 catalogued ops, dispatch via inventory, witnesses emitted, replay-deterministic — but does NOT declare `ops.tier = 'Tier2` in its `.ontoref/config.ncl`. The tier is inferred from artefact presence (`catalog/operations/*.ncl`, `crates/ontoref-ops/`), not from a deliberate config statement. - The 18 other onboarded projects (mirador, lian-build, vapora, …) run at tier-0 by config-default (`#[serde(default)]` on `OpsConfig` yields `Tier::Tier0`). - No project today exercises the tier_guard's failure path because no project has crossed a tier boundary since ADR-029 was accepted.

OPEN QUESTIONS ENTERING THIS SESSION

1. Op vs CLI vs both — where does `transition_tier` live? 2. Direction asymmetry — what does downgrade actually do? 3. TierGuard wiring point — at config-reload, at op pre-validation, or at both? 4. Pending-migrations definition — strict or scoped? 5. Effect catalog — concrete list of substrate effects per direction. 6. Atomicity — transactional or compensating?

The decisions below resolve each open question through the ondaod- synthesis lens (formalization-vs-adoption, ontology-vs-reflection) and preserve the voluntary-adoption axiom invariant=true.

Decision

ONE PRIMITIVE, TWO SURFACES, ONE LOGIC

The operational primitive is named `transition_tier`. It exists at two operational surfaces backed by a single Rust implementation:

SURFACE A — CLI command (every tier) `ontoref tier set <Tier0|Tier1|Tier2>` Available at every tier including tier-0 (where the op dispatcher is not present). Internally calls `ontoref_daemon::tier_transition::execute(target_tier, ctx)`.

SURFACE B — tier-2 catalogued operation `catalog/operations/transition_tier.ncl` + Rust handler annotated `#[onto_operation(id = "transition_tier", validation_sla = "Synchronous")]`. Materialises automatically once a project reaches tier-2. Emits a signed witness (Ed25519) covering the tier change. Calls the same `ontoref_daemon::tier_transition::execute` function.

The CLI is the universally-available path; the catalogued op is the witnessable path. Below tier-2 the CLI is the only surface; at tier-2 both surfaces are available and BOTH route through the same Rust function. This preserves ondaod (formalization-vs-adoption Spiral stays open) by NOT forcing tier-0/1 projects to bootstrap a dispatcher to change tier, AND by NOT letting tier-2 projects bypass witness emission when they have the substrate to produce one.

GUARD WIRING — single entry-point, two reachability paths

`enforce_tier_transition_guard` fires at exactly one logical entry-point — inside `ontoref_daemon::tier_transition::execute` — regardless of surface. Both CLI and op converge on this function as the first validator. Additionally, the daemon's existing `ConfigWatcher` (which observes `.ontoref/config.ncl` changes) consults the same guard before swapping the config in its registry. The three reachability paths:

a. CLI invocation → execute() → guard b. Op dispatch → execute() → guard c. Manual NCL edit + daemon reload → ConfigWatcher → guard

If the guard returns `Err(MigrationsPending)`, the function returns without applying any effect; the daemon emits a `Block`-severity notification; the registry retains the old tier value; manual NCL edits are NOT swapped in (the daemon does not unilaterally overwrite the file — it logs the block and lets the developer revert their edit or `ontoref migrate` first).

PENDING-MIGRATIONS DEFINITION — strict

The guard treats `ontoref migrate pending` as authoritative. Any pending migration blocks any tier change. This matches the current `tier_guard.rs` implementation exactly and avoids introducing a new optional schema field (`relevant_to_tier_transition`) whose semantics would themselves be a Spiral collapse: the protocol cannot reliably classify which migrations affect tier mechanics without empirically testing every migration against every tier transition, and the cost of that classification exceeds the cost of running pending migrations first.

DOWNGRADE — mark dormant, never delete

`transition_tier` lowering `ops.tier` (e.g. Tier2 → Tier0) does NOT delete substrate artefacts. The behaviour matrix:

| direction | NCL files | oplog | commit_layer | state_root | signing keys | | ---------------- | --------- | ----- | ------------ | ---------- | ------------ | | upgrade N→N+1 | preserved | created if absent | created if absent | initialised | regenerated if absent | | downgrade N→N-1 | preserved | preserved (read-only) | preserved (read-only) | frozen (last value retained) | preserved (read-only) | | downgrade N→N-2 | preserved | preserved (read-only) | preserved (read-only) | frozen (last value retained) | preserved (read-only) |

This is consistent with `ontoref-tier-downgrade-asymmetry` QA: past cryptographic guarantees survive immutable, future guarantees pause, and re-upgrade introduces an externally-observable epistemic gap in the witness chain (not a protocol bug — an inherent property of cryptographic chains).

EFFECT CATALOG

The function applies effects per direction in a fixed order, with each effect carrying a compensating rollback action (see ATOMICITY below):

Tier0 → Tier1 e1. Create `.ontoref/store/oplog/` if absent e2. Bootstrap commit_layer (write empty root commit if oplog empty) e3. Compute initial state_root from NCL ingestion e4. Register the project's substrate state in daemon registry e5. Mutate `.ontoref/config.ncl::ops.tier = 'Tier1` (atomic file replace via tempfile + rename)

Tier1 → Tier2 e1. Verify or create per-actor Ed25519 keypair (`~/.config/ontoref/keys/<actor>.key`, 0600) e2. Register the project's catalog by reading `.ontoref/catalog/operations/*.ncl` (declarative side) e3. Verify each declared op has a corresponding registered OperationEntry (via `inventory::iter`) OR a declared non-Rust kind per ADR-034 e4. Mutate `.ontoref/config.ncl::ops.tier = 'Tier2`, `ops.phase = 'Progressive` (atomic)

Tier2 → Tier1 e1. Emit a final tier-descent witness (Ed25519-signed envelope describing the descent, written to oplog as the last signed entry — the oplog remains valid for verifiers) e2. Mutate `.ontoref/config.ncl::ops.tier = 'Tier1` e3. Update daemon registry to stop dispatching ops for this project (return 404 from `/ops/{id}` while preserving witness verifiability)

Tier1 → Tier0 e1. Update daemon registry to stop maintaining the substrate (commit_layer becomes read-only archive; new NCL edits are NOT ingested into the commit_layer) e2. Mutate `.ontoref/config.ncl::ops.tier = 'Tier0`

Tier2 → Tier0 (compound descent) Decomposed into (Tier2 → Tier1) followed by (Tier1 → Tier0), applied atomically as one transaction. If e1 of the inner descent succeeds but e2 of the outer descent fails, the WHOLE compound rolls back. The function never leaves the project at an intermediate tier value than the one declared.

ATOMICITY — transactional, all-or-nothing

The function applies all effects within a single transactional scope. Each effect has a recorded compensating action. On any failure:

- The compensations execute in reverse order - `.ontoref/config.ncl::ops.tier` is restored to its pre-transition value (the file rename is the last effect; if it succeeds, the transition committed) - The daemon's in-memory registry is restored to its pre-transition snapshot - The failed transition is recorded in `.ontoref/artifacts/transition-log/<timestamp>-failed.ncl` for operator review (logged, never silently discarded)

The transactional model means the user sees one of exactly two outcomes: the project is at the requested tier OR the project is at its original tier; never an intermediate state. Synchronous SLA on the catalogued op enforces this externally; the CLI surface has the same guarantee because it calls the same Rust function.

ONTOREF-EL-PROYECTO DECLARES `ops.tier = 'Tier2`

As part of this ADR — not a separate housekeeping step — ontoref's own `.ontoref/config.ncl` gains an explicit `ops = { tier = 'Tier2, phase = 'Progressive }` block. This makes the piloto self-host the FIRST project to formally declare its tier and the canonical example for every future declaration. The Hard constraint `piloto-declares-its-tier` (below) pins the declaration so future sessions cannot silently remove it.

NEW SCHEMA FIELDS — none in this ADR

`transition_tier` operates on the existing `OpsConfig` struct (tier + phase). It does not require new schema fields. Migration 0024 introduces no breaking change — it (a) documents the new CLI command and op, (b) instructs ontoref-el-proyecto to add the explicit tier declaration, and (c) recommends consumer projects add their own declaration when they choose a tier.

Constraints

  • Hard The `transition_tier` operational primitive (CLI surface, catalogued op, and ConfigWatcher hook) MUST invoke `enforce_tier_transition_guard` from `crates/ontoref-ops/src/tier_guard.rs` as its first validator. No alternative implementation of the precondition check is permitted; the guard is single-source.
  • Hard The CLI surface (`ontoref tier set <T>`) and the tier-2 catalogued op (`transition_tier`) MUST both route through a single Rust function `ontoref_daemon::tier_transition::execute` (or its successor module). Neither surface may implement the transition logic independently.
  • Hard Every invocation of `transition_tier` MUST produce exactly one of two observable outcomes: (a) the project is at the requested tier with all effects applied OR (b) the project is at the original tier with no effect persisted. The function MUST NOT leave the project at any intermediate tier value, and MUST NOT leave any partially-applied substrate state visible to subsequent reads.
  • Hard Downgrade transitions (any direction where `new_tier < old_tier`) MUST NOT delete, truncate, or otherwise mutate the existing oplog, commit_layer, state_root snapshot, or actor signing key files. The substrate becomes read-only archive; new mutations follow the new tier's rules.
  • Hard When `transition_tier` is invoked with `old_tier = 'Tier2` AND `new_tier != 'Tier2`, the function MUST emit a final Ed25519-signed witness covering the descent before mutating `.ontoref/config.ncl::ops.tier`. The witness payload kind is `tier_descent`; the proof path references the pre-descent state_root.
  • Hard The ontoref repository's own `.ontoref/config.ncl` MUST contain an explicit `ops` section declaring `tier = 'Tier2`. The declaration MUST be a deliberate config statement, not inferred from artefact presence.
  • Hard The daemon's ConfigWatcher MUST consult `enforce_tier_transition_guard` before swapping a project's `OpsConfig` in the registry when the watcher detects a change to `.ontoref/config.ncl::ops.tier`. When the guard returns `Err`, the watcher MUST log a `Block`-severity notification and retain the previous config; it MUST NOT mutate the user's NCL file to revert the change (only refuses to honour it).
  • Hard The guard MUST treat any non-empty result from `ontoref migrate pending` as blocking. The function MUST NOT introduce per-migration opt-out fields (e.g. `relevant_to_tier_transition`) that would relax the constraint.
  • Soft Every failed transition (whether blocked by the guard or by a downstream effect failure) MUST produce a record in `.ontoref/artifacts/transition-log/<timestamp>-failed.ncl` containing: the requested target tier, the original tier, the failure source, and the timestamp. The record MUST NOT contain sensitive substrate state (no private key material, no secret values).

Alternatives considered

  • CLI-only — `transition_tier` lives in `domains/framework/commands.nu` as a Nushell command calling daemon HTTP routesrejected: Tier-2 projects need the act to be witnessable per ADR-024 ('operations are the agent's only path to mutate state, with declared preconditions, validators, and witness emission'). A CLI-only implementation either bypasses the catalog (anti-ADR-024) or duplicates witness emission logic outside the catalog (state inconsistency risk). Dual-surface with one-logic is the synthesis that preserves both surfaces' guarantees.
  • Tier-2-op-only — only a catalogued op, no CLI surfacerejected: Tier-0/1 projects don't have the op dispatcher (the op runtime is part of the tier-2 substrate). They cannot climb without first bootstrapping the dispatcher — chicken-and-egg. CLI is the bootstrap surface that breaks this circularity. Removing it would make tier transitions impossible for the lowest tier, violating voluntary-adoption (a tier-0 project must be able to climb when it chooses to).
  • Separate per-direction primitives — `upgrade_tier`, `downgrade_tier`, `set_phase`rejected: Three primitives where one suffices. The function signature `execute(target_tier, ctx)` covers all directions; the effect catalog branches per direction internally. Splitting introduces three places to apply the guard, three CLI commands, three op declarations — without adding semantic clarity. The brief's `ontoref-tier-downgrade-asymmetry` QA already explained that downgrade has different effects than upgrade; that asymmetry lives inside the effect catalog, not in three separate primitives.
  • Pending-migrations scoped by `relevant_to_tier_transition: true` fieldrejected: Adding a per-migration boolean would require the protocol to pre-classify every future migration. The classification is itself a Spiral-collapse decision (which migrations 'matter' for tier mechanics is precisely what ADR-029's constraint refuses to pre-judge). Strict-any matches the current `tier_guard.rs` exactly, requires no schema change, and the operational cost (run migrations before transitioning) is the obvious workaround.
  • Best-effort with rollback log — apply effects, log completions, manual cleanup on failurerejected: ADR-024 makes operations the agent's only action path with witness emission per op. An op that partially applies state breaks the witness/state correspondence — the Ed25519 signature covers a state the verifier cannot reconstruct from the oplog. Transactional all-or-nothing is the only model compatible with witness-honesty. The cost (compensations per effect) is bounded and one-time.
  • Delete substrate on downgrade — clean slaterejected: Anti-ADR-029. Past witnesses are immutable per voluntary-adoption; deleting the oplog / signing keys would invalidate them from external verifiers' perspective. The `ontoref-tier-downgrade-asymmetry` QA explicitly states 'past witnesses verify forever against actors.ncl public keys' — that property requires keeping the keys and the oplog. Dormancy preserves the property at the cost of disk space; deletion breaks it.

Related ADRs

ADR-018 · ADR-023 · ADR-024 · ADR-025 · ADR-026 · ADR-029 · ADR-030 · ADR-032

Was this useful? Rate it
Got something to add? Tell me what you think, what you'd suggest, or whether we should keep exploring this topic.
· reads

We use cookies to help this site function, understand service usage, and support marketing efforts. Cookie Policy for more info.