This tutorial builds a small, honest Vial for one repeated decision. It starts with the profile, not the screen: the profile is the program, the UI is how a person uses it, and the fixture is the proof that the program does what the author says.
The example values below are synthetic. They show the shape of a fixture, not a measured result. A listed Vial needs a real held-out report and real metrics; the registry review refuses placeholders and unchanged demo bytes.
git clone https://github.com/OpenSmell/opensmell-rs
cd opensmell-rs
cargo install --path . --bin noses
noses versionnoses is the local authoring gate. It is not the runtime and it does not sign or upload anything. The desktop host re-runs the same contract when a user installs the Vial.
noses new "Fermentation Lab" --publisher yourname --domain fermentation
cd fermentation-labThe scaffold contains:
| File | Role |
|---|---|
vial.json | The manifest: identity, scope, limits, anomaly profile, evidence, widgets, and UI declaration. |
ui.html | A sandboxed body fragment. No network, storage, Tauri, forms, or external assets. |
fixtures/ | Deterministic sample sequences with expected states. Add at least one. |
README.md | The authoring loop and the deliberate gaps the gate refuses. |
The scaffold is deliberately incomplete: citations are empty, the text carries REPLACE ME markers, and the evidence block has no report. noses lint refuses until you fill them. That is the point: a scaffold must never be publishable by accident.
The id is permanent and globally unique: publisher.slug. The scope is the one repeated decision this Vial supports. The limits are the conditions where it refuses or where the user must stop. Keep both concrete and operational.
{
"schema": "osmograph.domain-pack/2",
"id": "yourname.fermentation-lab",
"name": "Fermentation Lab",
"version": "0.1.0",
"author": "@yourname",
"license": "MIT",
"risk": "medium",
"domain": "fermentation",
"tagline": "See the batch change before the taste does.",
"summary": "A batch-scoped workspace that pairs a fermentation log with a profile for the change the array can actually resolve.",
"scope": "Support one repeated decision: whether this batch has moved outside its own established baseline during the logged window.",
"limits": [
"Refuses to arm before the baseline is ready or when fewer than two declared channels are live and non-dead.",
"The array does not identify a compound; a person confirms the cause with the primary measurement."
],
"citations": [
"Author, year, title, venue. A real reference for the protocol or the sensing mechanism."
]The profile is the part that changes what the instrument does. Start from the process, not from a default: how long does a clean baseline take, what change matters, how long must it persist, and how quickly must it clear?
{
"anomaly": {
"baseline": {
"protocol": "first-n",
"warmupS": 60,
"r0WindowS": 10
},
"transforms": {
"conditioning": ["median3"],
"response": "delta-over-r0"
},
"detection": {
"enabled": true,
"sensitivity": 1.0,
"minChannels": 2,
"levelBudget": 0.0,
"levelRefS": 0.0,
"alertThreshold": 0.5,
"perChannelLimits": {
"ch1": 0.12,
"ch2": 0.12
},
"hysteresis": 0.2,
"escalation": {
"warningS": 30,
"criticalS": 120,
"emergencyS": 300,
"resetNormalS": 300
},
"farTargetPerMonth": 1.0
},
"admissibility": {
"requireBaseline": "ready",
"minCoverage": 0.8
}
}
}warmupS is the clean baseline window. A slow process needs a longer one; shortening it to make the demo feel fast is how false alarms are born.minChannels: 2 demands corroboration. It cuts single-channel noise at the cost of missing a change that only one channel can see.escalation.warningS: 30 is the persistence lever: a 20-second spike does not become a warning.hysteresis: 0.2 means the signal must fall to 80% of the peak that raised the alarm before it can clear. This stops threshold chatter.The full field table and the false-alarm trade-offs are in the profile chapter.
A fixture is a deterministic sample sequence and the states it must reach. It is how the author proves the declared persistence, hysteresis, and escalation on a desk, without a rig. Add at least one and reference it from the evidence block.
{
"schema": "osmograph.vial-fixture/1",
"description": "Synthetic step: baseline at 1.00, then 1.20 on both channels.",
"dtS": 1.0,
"samples": [
{ "t": 0, "channels": { "ch1": 1.00, "ch2": 1.00 } },
{ "t": 1, "channels": { "ch1": 1.00, "ch2": 1.00 } },
{ "t": 2, "channels": { "ch1": 1.00, "ch2": 1.00 } },
{ "t": 3, "channels": { "ch1": 1.20, "ch2": 1.20 } },
{ "t": 4, "channels": { "ch1": 1.20, "ch2": 1.20 } },
{ "t": 5, "channels": { "ch1": 1.20, "ch2": 1.20 } }
],
"expected": {
"final": "warning",
"alerts": [{ "t": 3, "level": 1 }],
"toleranceS": 1.0
}
}{
"evidence": {
"grade": "B",
"reportUrl": "https://raw.githubusercontent.com/yourname/fermentation-lab/main/EVIDENCE.md",
"measuredAt": "2026-10-25",
"dataset": "Held-out batch on the six-channel rig, one preparation, two sessions.",
"metrics": {
"farPerMonth": 0.5,
"latencyS": 42,
"mde": 0.12,
"nSamples": 1800,
"nAlarms": 6
},
"fixtures": [
{ "id": "step", "file": "fixtures/step.json", "expect": "warning" }
]
}
}Those metrics are placeholders for the tutorial. Replace them with the numbers in your held-out report. The review checks the report, the exposure time, and the fixture; it does not infer a measurement from an empty block.
A Vial UI receives a typed payload and returns bounded actions. The payload carries the host snapshot, a downsampled series, the available sources, the host change log, and the Vial's local state. It does not read raw buffers and it cannot change the detector. Start with the host truth: baseline state, anomaly state, the channels that triggered, and the channel health.
<style>
body { margin: 0; padding: 16px; color: #1a1a17; background: #fbf8f2; font: 13px/1.5 ui-monospace, monospace; }
p { border-bottom: 1px solid #d8d4ca; padding: 8px 0; margin: 0; }
button { font: inherit; border: 1px solid #1a1a17; border-radius: 0; background: transparent; padding: 4px 8px; }
</style>
<main>
<p id="baseline">Baseline: waiting for the host…</p>
<p id="state">State: unknown</p>
<p id="channels">Channels: —</p>
<button id="log">Log this reading</button>
</main>
<script>
const baseline = document.getElementById("baseline");
const state = document.getElementById("state");
const channels = document.getElementById("channels");
osmograph.onData((payload) => {
const snapshot = payload.snapshot;
baseline.textContent =
snapshot.baseline.state === "ready"
? "Baseline: ready"
: "Baseline: " + snapshot.baseline.state +
" (" + Math.round(snapshot.baseline.progress * 100) + "%)";
state.textContent =
"State: " + snapshot.anomaly.state +
" · level " + snapshot.anomaly.alertLevel;
channels.textContent =
"Triggered channels: " + snapshot.anomaly.triggeredChannels;
});
document.getElementById("log").addEventListener("click", () => {
osmograph.action({
type: "add-log",
values: { note: "Reviewed the live reading" }
});
});
osmograph.ready();
</script>The sandbox blocks network, storage, forms, external assets, and Tauri. The payload is the only data path; the snapshot is the only live host state you may depend on. Read the UI chapter for the full action set and the color laws.
noses lint
noses testlint checks the manifest, the profile ranges, the evidence block, citations, the UI sandbox, and placeholders. test runs the fixture against the declared profile state machine and checks that the UI uses only host bridge calls. A fixture failure is an error.
✓ schema osmograph.domain-pack/2: profile, UI, and evidence.
✓ scope State the decision this Vial supports.
✓ limits Name at least one condition where the Vial refuses or stops.
✓ anomaly Baseline, transforms, detection, and escalation are declared.
✓ anomaly-values Profile values are in range.
✓ evidence A v2 Vial needs an https evidence report, the dataset it describes…
✓ citations At least one real reference.
Profile fixtures (deterministic, no hardware)
✓ step fixtures/step.json → warning (1 alert(s))
Programmable surface (static, no hardware)
ui → ui.html
onData declared
setTransforms —
setDetection —
setTrajectory —A v2 Vial does not call setTransforms or setDetection. Those actions exist for legacy manifests; in v2 the profile is compiled from the manifest and the UI cannot loosen it.
Push the directory to a public repository. The manifest must be at the repo root so the raw URL resolves. Then ask noses publish for the hashes and the exact registry entry shape.
git init
git add vial.json ui.html fixtures EVIDENCE.md README.md
git commit -m "v0.1.0"
git remote add origin https://github.com/yourname/fermentation-lab
git push -u origin main
noses publish --repo https://github.com/yourname/fermentation-labOpen /appstore, choose Submit a Vial, and paste the raw manifest URL. The server fetches the manifest, validates the listing contract, computes the sha256 of the manifest and every UI asset, and opens a GitHub issue with those pins. A maintainer approves it in /admin. Approval re-fetches the bytes and refuses if anything changed.
If the deployment has no registry token, the form falls back to the email submission path. The hashes and the review still apply; the queue is simply manual.
If the author publishes a changed file without bumping the version, the install refuses: the hash mismatch is a hard refusal.