Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .workhorse/plans/g1/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# G1 · Display compute requirements in form submission results

## Goal

The finalised artifact and its PDF should present concrete compute requirements
(processor, memory, storage, network, OS/software) for each server and device
class actually present in the deployment. The numbers are the authoritative
recommended base-level specs from the Tamanu "Compute resource recommendations"
reference; which classes appear is driven by the answers.

## Design

Keep the ruleset as data. Add a `requirements` block to the ruleset, evaluated
by the engine exactly like `rules`: each entry has a `when` condition and a
requirement profile (server/device class, a short who-provisions summary, a list
of spec rows, an optional note). The engine emits the union of triggered
requirements; the artifact renders them in a new "Compute requirements" section.

Requirements surface only for classes someone must act on / provision:

- **Central server** — when client-hosted (`central == clienthosted`). BES-cloud
Central is provisioned by BES, so it carries no client-facing requirement.
- **Facility server** — when client-hosted facilities are present and not every
site runs an Iti (`hosting_where` allclient/mix AND `iti_use != all`).
- **Tamanu Iti mini-server** — when `iti_use` is some/all. BES-built; only its
network needs stating.
- **User devices (workstations)** — always. The client provides these regardless
of hosting.
- **Mobile devices** — when mobile users are in play (`mobile` m1/m2/m3).

Numbers are the recommended base level from the reference doc. Per-size scaling
is deliberately out of scope: the doc gives one base tier and says higher tiers
are advised separately by BES, and inventing per-band figures would state costs
nobody has confirmed. The size band already appears in the artifact header.

## Steps

- [x] Add `Requirement` + `Spec` to the ruleset model; `requirements` on `Ruleset` (serde default)
- [x] Validate requirement id uniqueness in `Ruleset::validate()`
- [x] Emit `requirements` (`TriggeredRequirement`) from the engine `evaluate()`
- [x] Export the new types from `ruleset/mod.rs`
- [x] Author the `requirements` block in `ruleset.ron` with the reference specs
- [x] Regenerate `web/openapi.json` + `api-types.ts`; add wire type re-exports
- [x] Render a "Compute requirements" section in `Artifact.tsx` (+ CSS)
- [x] Update the WIZ spec: outputs, PDF ordering, engine model note
- [x] Rust engine tests for presence gating; extend `tests/ruleset.rs`
- [x] Create `.workhorse/test-cases/g1/overview.md`
- [x] `just check`, `just test`, frontend typecheck

## Refinement: size-scaling (after the price-list resource)

The price list gives authoritative per-band server specs, so compute requirements
now scale with the derived size band instead of showing one baseline tier.

- Server profiles (Central, Facility) carry size-invariant rows in `specs`
(network, OS) plus per-band rows in `by_size` (processor, memory, storage). The
engine resolves `by_size` against `derived["size"]` and leads with those rows.
- Figures follow the recommended on-prem tower tiers (small 2c/16GB/480GB,
medium 4c/16GB/960GB, large 8c/32GB/2TB); Tiny reuses small figures plus an
advisory to host with BES or use an Iti rather than buy a server. Make is a
suggestion, not a requirement.
- The Iti profile now states its one-model hardware spec (4c/8GB/500GB SSD).
- Only client-provisioned classes appear (what they'd buy to self-host); BES-cloud
servers stay off — confirmed with the user.
- Cost/pricing deferred: user chose "cost tier only" and will return to pricing.
A per-server tier would just restate the deployment size band, so no cost rows
this pass. Wire type and frontend unchanged (size resolved server-side).

- [x] Model: `by_size`/`SizeSpecs`, validation, engine resolution, ruleset rework
- [x] Tests for size-scaling + Tiny advisory; spec updated to match
- [x] `cargo test`, clippy, frontend build, playwright e2e all green
41 changes: 33 additions & 8 deletions .workhorse/specs/wizard/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ Authored prose (consequence detail, question help, option notes, guidance) may
carry limited inline markup: links, which open in a new tab, and light emphasis,
so it can point to further documentation.

The ruleset also carries a compute requirement profile for each class of server or device, each gated by the same trigger conditions.
The engine surfaces the profiles whose class is present in the deployment, which the artifact renders as the [Compute requirements](#compute-requirements).

### Visibility and forward guidance

The engine shows a question only when its precondition holds, and hides it otherwise; a precondition is a presence-of-class flag or a cross-field condition.
Expand Down Expand Up @@ -226,11 +229,13 @@ This comparison is the "what changed" summary the user sees on update: newly-req
### Finalised web view

The canonical artifact is a live page.
It presents every consequence in full, in four groups: warnings, being what the client is opting into by leaving the standard path, then what the client's IT team has to do, then what the BES technical team sets up, then what the BES pricing and partnerships team has to price or commit to.
Warnings lead it: what the client is opting into by leaving the standard path is met before anything else.
The compute requirements follow (see [Compute requirements](#compute-requirements)), being the answer the reader came for.
Beneath those, under a **Next steps** heading that frames them as who does what, it presents the remaining consequences in full, in three groups: what the client's IT team has to do, then what the BES technical team sets up, then what the BES pricing and partnerships team has to price or commit to.
An item states the action it asks for, so a group of required actions reads as a list of work rather than a list of observations.
A choice off the standard path that also asks something of a team produces two items: the work, which sits with that team's actions, and the acknowledgement of what the choice costs, which sits with what is being opted into.
Nothing off the standard path appears in a group of actions, because an acknowledgement is not something anyone does.
Warnings come first and arrive collapsed, carrying the same off-default colour they carry everywhere else: they are context for the work below rather than the work itself, and a conflict that stops the configuration working is raised by the viability callout regardless.
Warnings arrive collapsed, carrying the same off-default colour they carry everywhere else: they are context rather than the work itself, and a conflict that stops the configuration working is raised by the viability callout regardless.
Each group can be collapsed, so a reader can skip past the groups addressed to someone else.
A section can be linked to directly.

Expand All @@ -246,6 +251,25 @@ Its header surfaces non-identifying recognition facts so one artifact is disting
A fact the engine assumed reads the same as one the reader chose, because it is the answer until they change it.
Settings that exist to be acted on, such as the hosting region, appear with the work they imply rather than in the header.


### Compute requirements

The compute requirements answer a single question for the reader: what would they have to provide themselves to run this deployment.
So the artifact states the concrete requirements for each class of server and device the client provisions, and only those.
Each class is presented as its own block: the class name, a short line on who provisions it, a set of labelled spec rows leading with processor, memory and storage and then network and operating system or software, and an optional note.

Which classes appear is driven by the answers, so a reader sees only what their deployment needs someone to buy or provide.
A class BES provisions itself, such as a central or facility server hosted in BES cloud, carries no block, because the client provides nothing for it.
So the central server appears only when the client hosts it; a facility server appears when a client-hosted facility runs its own server rather than a mini-server; the Tamanu Iti mini-server, which the client buys from BES, appears when any site uses one; the user devices staff work at always appear; and mobile devices appear when the deployment has mobile users.

A server's processor, memory and storage scale with the deployment's derived size band, drawn from the recommended per-band figures; its network row is the same at every size.
A row may instead be tied to an answer, so the operating system row names the platform the reader chose rather than listing what is available.
Working out what is needed is the tool's job, so the requirements state it plainly rather than hedging that a larger deployment might need more.
The make of a server is a suggestion, never a requirement: the block leads with the specification a server must meet, not a product to buy.
For the smallest deployments the block advises hosting with BES or using a mini-server rather than buying a server at all, since dedicated hardware rarely pays off at that scale.
Devices that do not scale, such as workstations and phones, state one recommended specification.
Indicative pricing is out of scope here and is settled by the pricing and partnerships team, who already receive an item to price the hosting.

### PDF export

The artifact can be exported to PDF, sectioned by audience, as a static snapshot derived from the same data.
Expand All @@ -255,12 +279,13 @@ Its sections, in order:
1. **Viability verdict**: any blocking conflicts, at the top.
2. **Open questions**: on an interim artifact, what is still to be settled, before any of the detail that rests on it.
3. **Warnings**: what the client is opting into by leaving the standard path.
4. **Client IT team, required actions**: ports, outbound endpoints, DNS, remote access, time synchronisation.
5. **BES technical team, setup decisions**: staging, topology, platform, server specifications, backup and retention, region.
6. **BES pricing and partnerships**: what has to be priced, and what BES can commit to supporting.
7. **Referrals**: items escalated to a separate conversation.
8. **Assumptions**: the answers the engine filled in where the reader left a question blank.
9. **Full decision record**: everything captured.
4. **Compute requirements**: the specs for each server and device class the deployment uses.
5. **Client IT team, required actions**: ports, outbound endpoints, DNS, remote access, time synchronisation.
6. **BES technical team, setup decisions**: staging, topology, platform, backup and retention, region.
7. **BES pricing and partnerships**: what has to be priced, and what BES can commit to supporting.
8. **Referrals**: items escalated to a separate conversation.
9. **Assumptions**: the answers the engine filled in where the reader left a question blank.
10. **Full decision record**: everything captured.

## Data and confidentiality

Expand Down
34 changes: 34 additions & 0 deletions .workhorse/test-cases/g1/overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# G1 · Compute requirements in results

Scenarios verifying the artifact presents compute requirements for the classes a
deployment actually uses, drawn from the ruleset (verifies spec: WIZ, Compute
requirements).

## Engine: which classes surface

- [x] Default path (BES-cloud Central, all-client facilities, no mobile) surfaces facility server and user devices, but not Central, Iti, or mobile
- [x] A fully BES-cloud deployment with no mobile surfaces only user devices
- [x] Client-hosted Central surfaces the Central server requirement
- [x] Mobile users surface the mobile device requirement; an unsure mobile count surfaces nothing
- [x] Some sites on Iti keep the facility-server requirement and add the Iti one; every site on Iti drops the facility server and keeps only Iti
- [x] Every requirement profile in the ruleset names a class and has at least one spec row (flat or per-band)

## Size scaling

- [x] A client-hosted server's processor/memory/storage scale with the derived size band (Tiny vs Large give different rows)
- [x] Size-varying rows lead; network and OS rows follow
- [x] The smallest band carries an advisory to host with BES or use an Iti rather than buy a server; larger bands do not
- [x] The Iti profile states its one-model hardware spec (4c/8GB/500GB), not size-varying
- [x] A draft not yet sized (bands unanswered) falls back to the lightest band's rows

## Artifact rendering

- [x] The finalised web view shows a "Compute requirements" section below the consequence groups, one block per present class, each with its spec rows
- [ ] Each block shows the who-provisions summary and, where authored, the note
- [x] The section is absent from no deployment (user devices always present, so it never renders empty)
- [ ] The PDF export includes the compute requirements with every block expanded

## Ruleset integrity

- [x] The bundled ruleset with requirements parses, validates (unique requirement ids), and hashes deterministically
- [x] An older artifact bound to a ruleset without a `requirements` block still loads (serde default)
66 changes: 65 additions & 1 deletion crates/pollen-server/src/ruleset/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

use super::answers::{Answer, Answers};
use super::model::{Consequence, DerivationKind, QuestionKind, Ruleset, Severity};
use super::model::{Consequence, DerivationKind, QuestionKind, Ruleset, Severity, Spec, SpecRow};

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Evaluation {
Expand All @@ -20,6 +20,9 @@ pub struct Evaluation {
pub visible_questions: Vec<String>,
/// Every triggered consequence, in ruleset order.
pub consequences: Vec<TriggeredConsequence>,
/// The compute requirements for the classes present in the deployment, in
/// ruleset order (spec WIZ, Compute requirements).
pub requirements: Vec<TriggeredRequirement>,
/// Guidance whose condition currently holds.
pub guidance: Vec<TriggeredGuidance>,
/// Visible questions left unanswered whose blessed-path default the engine
Expand Down Expand Up @@ -48,6 +51,17 @@ pub struct TriggeredConsequence {
pub consequence: Consequence,
}

/// A compute requirement whose class is present in the deployment. Carries the
/// profile's content (the `when` condition that selected it is not on the wire).
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TriggeredRequirement {
pub id: String,
pub class: String,
pub summary: Option<String>,
pub specs: Vec<Spec>,
pub note: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TriggeredGuidance {
pub at: String,
Expand Down Expand Up @@ -111,6 +125,55 @@ pub fn evaluate(ruleset: &Ruleset, answers: &Answers) -> Evaluation {
})
.collect();

// Requirements are gated on the same defaulted answers as consequences, so an
// assumed hosting choice surfaces the classes it implies. A sized class picks
// its rows from the derived size band, falling back to the lightest band when
// the deployment isn't sized yet (a draft before the bands are answered).
let size = derived.get("size").map(String::as_str);
let requirements: Vec<TriggeredRequirement> = ruleset
.requirements
.iter()
.filter(|r| r.when.eval(answers))
.map(|r| {
let by_size = (!r.by_size.is_empty())
.then(|| {
size.and_then(|s| r.by_size.iter().find(|ss| ss.size == s))
.or_else(|| r.by_size.first())
})
.flatten();
// Size-varying rows (processor, memory, storage) lead; the invariant
// rows (network, operating system) follow. A row gated on the answers
// is dropped unless it holds, so a requirement states the choice the
// reader made rather than every option.
let present = |rows: &[SpecRow]| -> Vec<Spec> {
rows.iter()
.filter(|row| row.when.eval(answers))
.map(|row| Spec {
label: row.label.clone(),
value: row.value.clone(),
})
.collect()
};
let mut specs = Vec::new();
if let Some(ss) = by_size {
specs.extend(present(&ss.specs));
}
specs.extend(present(&r.specs));
// A band-specific note augments the profile's own.
let note = match (r.note.clone(), by_size.and_then(|ss| ss.note.clone())) {
(Some(base), Some(band)) => Some(format!("{base} {band}")),
(base, band) => base.or(band),
};
TriggeredRequirement {
id: r.id.clone(),
class: r.class.clone(),
summary: r.summary.clone(),
specs,
note,
}
})
.collect();

let guidance: Vec<TriggeredGuidance> = ruleset
.guidance
.iter()
Expand Down Expand Up @@ -139,6 +202,7 @@ pub fn evaluate(ruleset: &Ruleset, answers: &Answers) -> Evaluation {
derived,
visible_questions,
consequences,
requirements,
guidance,
assumed,
open_items,
Expand Down
8 changes: 6 additions & 2 deletions crates/pollen-server/src/ruleset/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ pub mod source;

pub use answers::{Answer, Answers};
pub use condition::Condition;
pub use engine::{Assumed, Evaluation, TriggeredConsequence, TriggeredGuidance, Verdict, evaluate};
pub use engine::{
Assumed, Evaluation, TriggeredConsequence, TriggeredGuidance, TriggeredRequirement, Verdict,
evaluate,
};
pub use migrate::{Migration, migrate};
pub use model::{
Audience, Consequence, ConsequenceType, Cost, Derivation, DerivationKind, Guidance, Opt,
Question, QuestionKind, Rule, Ruleset, Section, Severity, Status,
Question, QuestionKind, Requirement, Rule, Ruleset, Section, Severity, SizeSpecs, Spec,
SpecRow, Status,
};
pub use resolver::{RULESET_PATH, ResolvedRuleset, RulesetResolver};
pub use source::{GitHubSource, RefSource};
Expand Down
Loading