<!-- nornir:class: guide -->
# PROMOTE — guide
How to describe your environments, and how to move an application between them
without breaking production. Design + the precise contract:
[`promote-design.md`](promote-design.md). Never done this before:
[`promote-for-idiots.md`](promote-for-idiots.md).
```toml
skidbladnir = { version = "0.1", features = ["promote"] } # pure core
skidbladnir = { version = "0.1", features = ["promote-k8s"] } # + live kube-rs ops
```
---
## 1. Describe your ladder
One file, in Skíðblaðnir's schema. Yours, not Njord's — the crate knows no rung
names.
```yaml
apiVersion: skidbladnir.nordisk/promote.v1
meta:
customer: Contoso
owner: ops@contoso.example
slots:
- name: bench
namespace: contoso-bench
hosts: [bench.contoso.example]
role: development
guarded: false
promotes_to: [staging-eu]
- name: staging-eu
namespace: contoso-stg
hosts: [stg.contoso.example]
role: pre-production
guarded: false
promotes_to: [apex]
- name: apex
namespace: contoso
hosts: [contoso.example, www.contoso.example]
role: production
guarded: true
meta: {ticket-queue: SRE}
```
Four fields are **required** and there is no default for any of them: `name`,
`namespace`, `role`, `guarded`. That is on purpose — an environment that does not
say what it is, is an error, not a test slot.
`promotes_to` is the direction **code** travels. Data travels the other way.
Load it:
```rust
use skidbladnir::promote::*;
let ladder = Ladder::from_file(Path::new("env/contoso/ladder.yaml"))?;
```
`Ladder::from_yaml(source, text)` and `from_json(source, text)` take a `source`
label that appears in every error message, so a validation failure names the file.
---
## 2. Declare a baseline, then layer config over it
**Never build a fence out of a config file alone.** Declare the guarded slots in
code, and let the file layer over them:
```rust
let baseline = SlotRegistry::from_slots(vec![
Slot::new("apex", "contoso", SlotRole::Production, true)
.host("contoso.example")
.host("www.contoso.example"),
])?;
let registry = SlotRegistry::load_over(&baseline, ladder, DemotionPolicy::Refuse)?;
```
The file may add slots and may **raise** a slot to guarded. It may not lower or
delete a guarded one: `Refuse` fails the load, `ReportAndKeepFenced` loads with
the slot still fenced and the attempt in `registry.demotions()`.
Print them. They also appear in `Topology::notes` and in every guard refusal:
```rust
for d in registry.demotions() { eprintln!("{}", d.line()); }
```
---
## 3. Clear the fence before you touch anything
```rust
let guard = Guard::new(®istry)
.with_override(Override::flagged()) // the --allow-prod flag was passed
.with_env(EnvSource::Process); // and PROMOTE_ALLOW_PROD must be "1"
let cleared = guard.assert(&TargetRef::Slot("staging-eu"))?;
if let Some(b) = cleared.banner() { eprintln!("{b}"); }
```
| you have | use |
|---|---|
| a slot name | `TargetRef::Slot("apex")` |
| a Kubernetes namespace | `TargetRef::Namespace("contoso")` |
| a bare host | `TargetRef::Host("contoso.example")` |
| a connection string | `TargetRef::Url("postgres://u:pw@db.contoso.example/app")` |
Picking the wrong variant cannot make the answer wrong — the registry matches
across every identity a slot has. `Url` only looks at the host, and everything
printed is credential-stripped.
**Clear every target of the operation, not just the first:**
```rust
let cleared = guard.assert_all(&[
TargetRef::Namespace(&ns),
TargetRef::Url(&database_url),
TargetRef::Url(&object_store_endpoint),
])?;
```
Clearing only the namespace is how a lab-guarded restore writes to a production
database.
### Unknown targets
By default an identifier no slot claims is **refused**. For a genuinely read-only
verb, opt out explicitly:
```rust
let g = Guard::new(®istry).allow_unknown(); // list / inspect / diff ONLY
```
### The clearance is a capability
`Cleared` cannot be constructed outside the guard, and every mutating call takes
one. It also remembers what it cleared:
```rust
cleared.covers("contoso-stg"); // true
cleared.covers("contoso-stg-20260721-1432"); // true — a generation of it
cleared.covers("contoso"); // false — different slot
```
---
## 4. Ask the ladder before you move
```rust
match registry.check_promotion("bench", "apex") {
Hop::Legal => …,
Hop::SkipsRungs(rungs) => …, // refuse, or override and REPORT the rungs
Hop::WrongDirection => …, // that is a data copy, not a promotion
_ => …,
}
```
Or in one call, fence included:
```rust
let (cleared, skipped) = guard.assert_promotion("staging-eu", "apex", allow_skip)?;
if !skipped.is_empty() {
eprintln!("⚠ skipped {}", skipped.join(", ")); // the override does not silence it
}
```
Data goes the other way, and upward is not overridable:
```rust
let cleared = guard.assert_data_flow("apex", "bench")?; // ok
let err = guard.assert_data_flow("bench", "apex"); // always an error
```
---
## 5. Implement `AppData` — once, in your own repo
Next to the code that knows your schema. The ship never reads your payload.
```rust
impl AppData for MyApp {
fn describe(&self) -> &str { "yvra" }
fn dump<'a>(&'a self, slot: &'a Slot, staging: &'a Path)
-> Fut<'a, Result<DumpReport>>
{
Box::pin(async move {
let tables = self.dump_tables(slot, staging).await?;
let mut r = DumpReport::new()
.part(CapturedPart::complete("database", Some(tables as u64)));
r = match self.fetch_objects(slot, staging).await {
Ok(n) => r.part(CapturedPart::complete("objects", Some(n))),
Err(e) => r.part(CapturedPart::skipped("objects", &format!("{e:#}"))),
};
Ok(r.watermark(self.read_watermark(slot).await?)
.app_meta(json!({ "schema_version": 51, "tables": … })))
})
}
…
}
```
### Rules to keep
* **Declare every part.** A part you never declare cannot be reported missing.
* **`skipped` is the honest form of "degrade quietly".** Never return a report
that says complete when a leg did not run.
* **`items: None` means unknown, never zero.**
* **`watermark` returning `Ok(None)` means unknown**, and the ship treats that as
"may discard newer data". Do not invent a value to make a check pass.
* **`app_meta` is your opaque channel.** Schema version, table list, object keys
— anything that must ride in the manifest. The ship stores and round-trips it
and never reads a key out of it.
* **`neutralise_after_load` returns what it changed**, so the operator is told.
### The five capture states
| state | when |
|---|---|
| `Complete` | everything this part covers is there |
| `Partial{captured, expected}` | some of it — both numbers, because 1-of-4000 and 3999-of-4000 differ |
| `Skipped` | not attempted (credentials absent, narrower scope asked for) |
| `Failed` | attempted and failed |
| `Unknown` | attempted, outcome could not be determined |
Anything but `Complete` **must** carry a `detail`.
---
## 6. Seal, verify, unseal
```rust
let report = app.dump(&slot, &staging).await?;
report.require_complete()?; // refuse early if you want to
let sealed = seal(&TarZstd::default(), &SealSpec {
staging: &staging,
out: &bundle,
source_slot: &slot.name,
version: "20260727",
taken_at: &now_rfc3339(),
app: app.describe(),
}, &report)?;
```
The verdict is written into `<bundle>.promote.json`, so three days later nobody
has to remember what scrolled past.
```rust
let v = verify(&TarZstd::default(), &bundle)?;
v.intact(); // do the BYTES verify?
v.is_usable(); // intact AND the capture was whole
```
Those are different questions. A partial bundle is byte-perfect.
```rust
let u = unseal(&TarZstd::default(), &bundle, &staging, LoadPolicy::strict())?;
```
`strict()` refuses an incomplete or corrupt bundle. `accept_incomplete()` /
`accept_corrupt()` are deliberate opt-ins, and `u.completeness()` still tells you.
---
## 7. The watermark gate
```rust
let bundle_wm = u.watermark(); // from the manifest
let target_wm = app.watermark(&target_slot).await?; // from the live slot
match bundle_wm.map(|b| b.would_discard(target_wm.as_ref())) {
Some(Discard::Nothing) => { /* proceed */ }
Some(d) => {
eprintln!("{}", d.line());
confirm_or_abort()?; // includes Discard::Unknown
}
None => confirm_or_abort()?, // the bundle has no watermark either
}
```
`requires_confirmation()` is true for everything except `Nothing`. **Including
`Unknown`** — the whole point.
Give your watermark a `magnitude` (row counts, object counts) as well as a
`mark`. A deletion does not move a timestamp, it removes one, so a mark alone
cannot see that two hundred rows vanished from the target.
---
## 8. Roll an image, with a rollback
```rust
let ops = KubeReleaseOps::connect().await?; // feature promote-k8s
let plan = RolloutPlan::new(ReleaseRef::new(&ns, "web", "web"), "registry/app:v2")
.wait(Duration::from_secs(240));
for line in plan.describe() { println!(" ↪ {line}"); } // this IS the dry run
let outcome = rollout(&ops, &cleared, &plan).await?;
println!("{}", outcome.line());
outcome.into_result()?; // only Rolled is success
```
| outcome | what it means |
|---|---|
| `Rolled` | the new image is live |
| `RolledBack` | it failed; the previous image is back and serving |
| `RollbackFailed` | **it failed and the rollback failed — the environment may be down** |
| `NoAnchor` | it failed and nothing was restored (no previous image was readable) |
Do not collapse these into "failed". They are different phone calls.
---
## 9. Generations — blue/green
```rust
let g = Generation::new("contoso-stg", now_unix)?; // contoso-stg-20260721-1432
let plan = switch_plan(
"contoso-stg",
ops.host_owner("stg.contoso.example").await?.as_deref(), // who owns it now
&g.namespace,
"stg.contoso.example",
&g.namespace, // the service name
3000,
"stg-tls",
)?;
for s in &plan { println!(" ↪ {}", s.describe()); } // the dry run
let log = run_generation_plan(&ops, &cleared, &plan).await?; // the real thing
```
The order is **wake → point → drop**, and it is fixed. Switching before waking
opens a window where the host resolves to a scaled-down generation.
The old generation is **not** put to sleep by a switch — that is
`sleep_plan(ns)`, a separate decision, so that switching back stays a matter of
seconds.
Rolling back is just another switch:
```rust
let back = switch_plan("contoso-stg", Some(&new_ns), &previous_ns, host, &previous_ns, 3000, tls)?;
```
`RAISE_LADDER` names the phases of bringing a whole new generation up, in order:
`Rbac → Namespace → Tls → Storage → Secrets → Release → Data → SwitchHost`. Walk
it; do not reorder it. `SwitchHost` is the commit point.
---
## 10. Helm
`promote` does not render charts and will not shell out to `helm`. Render where
the chart is owned and hand over the result:
```rust
let docs = helm_template_in_your_own_pipeline()?; // or KubernetesRenderer
ops.apply(&cleared, &ns, &ReleaseSource::Manifests { docs }).await?;
```
`ReleaseSource::Chart(ChartRef { … })` is a **declaration** — carried, compared
and reported, never executed. Passing it to `apply` returns a refusal naming the
fix.
Reading a release's stored values needs no chart and no helm binary:
```rust
let values = stored_release_values(client, &ns, "myapp").await?;
```
That is what `helm get values` would answer, read out of the
`sh.helm.release.v1.*` Secret — so it runs as a plain Kubernetes Job.
---
## 11. Show the ladder in a UI
```rust
let mut obs = Observations::new();
obs.insert("staging-eu".into(), Observation::green(&now_rfc3339()).revision("v1.4.2"));
obs.insert("bench".into(),
Observation::failed("rollout-timeout", "3/5 ready after 240s", &now_rfc3339()));
let topo = registry.topology(&obs); // serde, both ways
```
Hand `topo` to a `facett-pipeline` adapter. It has nodes, edges, a computed
`rank` for layout, declared roles, `guarded`, your `meta`, and a per-rung state.
**No Skíðblaðnir UI dependency exists or should be added.**
Honesty rules the panel must respect, because the engine enforces them:
* a rung with no observation is `NeverRun` — **never render it green**;
* `Unknown` is its own state ("we could not look"), not a failure and not health;
* `Failed` and `Unknown` always carry a `code` — show it;
* `topo.notes` carries any config that tried to lower the fence. Show that too.
`topo.not_green()` is your "needs attention" list, and it includes the never-run.
---
## 12. Gotchas
**A dry run is a plan, not a flag.** `*_plan()` and `describe()` are pure. If you
find yourself adding a `Mode::DryRun` branch inside something that talks to a
cluster, you have re-created the bug this design removed.
**`Ok(None)` from `watermark` is not "safe".** It is "unknown", and unknown
blocks.
**An empty `DumpReport` is not success.** It is what a dump that never ran looks
like.
**`intact()` is not `is_usable()`.** A partial bundle passes a checksum.
**A clearance is not transferable.** `cleared.covers(ns)` must be true, or the
call refuses.
**Two keys means exactly `"1"`.** `PROMOTE_ALLOW_PROD=true` does not arm the
override, on purpose.
**Never author a `rank`.** It is computed from the edges. Two sources of truth
about order is how they disagree.