diff --git a/README.md b/README.md index 47484bb..3a2a8fc 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,16 @@ the business's own words and held-out personas. what was sent in and where it stands. `email` and `tel` fields, and any field marked `private: true`, are projected away before it is shown. +- `events: { open: applied, active: onboarded }` on a need: the event + name a stage is entered by, where the builder's default (the stage's + own name, `received` for the first) is not what a live site already + has. Portal replays every record by event name, so a rebuild of a + site that holds records keeps the names its aggregates.yaml has, or + every record vanishes from its desk. `plans/uhhm.yaml` rebuilds + uhhm.no this way. +- `attended_by:` on a need: who moves cases that no desk button moves, + an automation listening on the bucket's events, written into + aggregates.yaml as portal's own annotation for the reader. ## Plan: the intake, read by a model, checked by a person diff --git a/plans/uhhm.yaml b/plans/uhhm.yaml new file mode 100644 index 0000000..06a96e9 --- /dev/null +++ b/plans/uhhm.yaml @@ -0,0 +1,181 @@ +# UHHM, uhhm.no: the studio that builds portal and carries redoal. +# Rebuilt from the live site (uhhm/questions) on 2026-09-23. The five +# buckets the site already holds keep their live state and event +# names (`events:`), so every record on the owners' desk stays where +# it is. Two are new: a business trying the trainer on its own +# workflow (`trials`, approved by a person before anything is built), +# and what reaches UHHM as the custodian of the redoal network +# (`custody_requests`, a desk of its own). +business: UHHM +language: en +contact: { name: Bendik Aagaard Lynghaug, email: bl@uhhm.no } +about: > + UHHM builds software around a clear argument: infrastructure, tools + and products for organizations that can say what problem they have. + It also builds and carries the redoal network - the gesture math, + the relay, the first node, and the portal this site runs on - and + answers for what that network keeps. + +groups: + - { id: owners, label: Owners } + # The people who answer for the redoal network: they host its first + # node and relay, keep what is left there, and decide what stays. + - { id: custodians, label: redoal custodians, invited_by: [owners] } +# Owners onboard into every desk; a custodian can vouch for another. +invites_by: [owners] + +doors: + - id: build + who: an organization with a problem software should solve, or a business that wants to see its own workflow as a working site before paying anyone + - id: join + who: someone who wants to build with us, hear from us now and then, or change this site + +needs: + - id: commission + who: an organization with a problem it wants software to solve + wants: a considered reply to a brief, from people who read the whole thing + door: build + lands_in: projects + handled_by: owners + stages: [open, accepted, declined] + done_when: [accepted, declined] + success: [accepted] + events: { open: submitted } + max_fields: 4 + collects: + - { name: name } + - { name: email, type: email } + - { name: project, type: textarea } + - { name: organization } + - { name: org_contact, optional: true } + + # The client feature: the trainer tried on a real business. A person + # approves each intake before anything is built from it. + - id: trial + who: someone who runs a business where requests, approvals and handovers get lost, and wants to see it as a site before committing to anything + wants: a first draft of a site for their own roles, built from their own words and approved by a person first + door: build + lands_in: trials + handled_by: owners + stages: [open, approved, previewed, declined] + done_when: [previewed, declined] + success: [previewed] + attended_by: > + approved -> previewed: the n8n workflow that builds the draft + site from an approved intake (docker, rauthy, portal content) + and sends the preview link. Until it exists, owners run + portal-trainer by hand and press the button. + max_fields: 6 + collects: + - { name: business } + - { name: email, type: email } + - { name: what_we_do, type: textarea } + - { name: who_comes_to_us, type: textarea } + - { name: what_we_keep_track_of, type: textarea } + - { name: who_is_involved, type: textarea } + - { name: what_slows_us_down, type: textarea, optional: true } + + - id: collaborate + who: someone who wants to work with us on what we build + wants: to be read as a person, and invited in if there is a fit + door: join + lands_in: applicants + handled_by: owners + stages: [open, invited, active, departed, declined] + done_when: [departed, declined] + success: [departed] + events: { open: applied, active: onboarded } + max_fields: 3 + collects: + - { name: name } + - { name: email, type: email } + - { name: interest, type: textarea } + - { name: note, type: textarea, optional: true } + + - id: subscribe + who: someone who wants to hear from us when there is something to say + wants: a note now and then, and an easy way out + door: join + lands_in: subscribers + handled_by: owners + stages: [open, unsubscribed] + done_when: [unsubscribed] + events: { open: subscribed } + moves_by: { unsubscribed: [submitter, owners] } + max_fields: 2 + collects: + - { name: name } + - { name: email, type: email } + + # This site proposes its own changes through itself. + - id: propose + who: a visitor who sees how a page on this site should change + wants: the change read by a person and, if approved, made + door: join + lands_in: development_proposals + handled_by: owners + stages: [open, approved, rejected] + done_when: [approved, rejected] + success: [approved] + events: { open: proposed } + max_fields: 3 + collects: + - { name: submitted_by } + - { name: email, type: email, optional: true } + - { name: page, optional: true } + - { name: rationale, type: textarea } + - { name: proposed_yaml, type: textarea } + + # The custodian role, on the front door: UHHM answers for the redoal + # network it hosts. Owners see these too, and move nothing. + - id: custody + who: someone who runs, builds on, or is affected by the redoal network and needs the people who keep its first node + wants: an answer from a custodian, and action where the network keeps something it should not + lands_in: custody_requests + handled_by: custodians + watched_by: [owners] + stages: [open, in_dialogue, resolved, declined] + done_when: [resolved, declined] + success: [resolved] + max_fields: 4 + collects: + - { name: name } + - { name: email, type: email } + - { name: about, type: select, options: [Running a node of my own, Building on the network, Something the network keeps about me, Something else] } + - { name: what, type: textarea } + + # Internal: a relationship worth tracking before it is a project. + - id: prospect + who: an owner who has met an organization worth following up + wants: it written down and walked through its states from the desk + enters_as: owners + lands_in: organizations + handled_by: owners + stages: [prospect, client, past_client] + done_when: [past_client] + skippable: [client] + events: { prospect: identified, client: became_client, past_client: churned } + max_fields: 2 + collects: + - { name: name } + - { name: contact } + - { name: note, type: textarea, optional: true } + +# Held out from every model that writes words. The first eight are the +# 2026-09-21 set (runs/uhhm-2026-09-21); the rest are for what is new. +personas: + - { id: architecture-firm, need: commission, text: "We run a small architecture firm and our project files are scattered across three cloud drives. We want to pay someone to set up proper storage and backups we control ourselves." } + - { id: climbing-gym-founder, need: commission, text: "I have an idea for a booking app for climbing gyms and I'm looking for a team that can build the first version with me." } + - { id: municipality-buyer, need: commission, text: "Our municipality needs an internal tool for tracking building permits. We are looking for a vendor who can scope it and deliver it." } + - { id: exhibition-visitor, need: subscribe, text: "I saw your drawing network at an exhibition and liked it. I don't want to get involved, I'd just like to know when there is news." } + - { id: tech-journalist, need: subscribe, text: "I write about Nordic technology. Keep me posted on what you release so I can cover it." } + - { id: rust-developer, need: collaborate, text: "I'm a Rust developer between jobs and I like that you build in the open. I would love to contribute, and maybe join you." } + - { id: interaction-designer, need: collaborate, text: "I'm an interaction designer. I want to work with you on the drawing interface." } + - { id: student-intern, need: collaborate, text: "I'm a computer science student looking for a summer internship doing infrastructure work." } + - { id: plumber-owner, need: trial, text: "I run a plumbing company with four vans. Jobs come in by phone and text and some get lost. I'd like to see how this would look for us before I spend any money." } + - { id: dance-school, need: trial, text: "We are a dance school. Enrolments, cancellations and invoices all go through one email inbox that only I understand. Can you show me a version made for us?" } + - { id: node-runner, need: custody, text: "I run a small server in Berlin and want to host a redoal node that peers with yours. Who do I talk to?" } + - { id: recorded-name, need: custody, text: "Someone left a recording on redoal that says my full name and where I live. I want it gone, and I want to know who is responsible for that network." } + - { id: app-builder, need: custody, text: "I'm building a music app and would like to use redoal's gesture addresses in it. Is that possible, and who decides?" } + - { id: wording-fix, need: propose, text: "The subscribe page should say how often you actually write. I'd like to suggest that exact change to the page." } + - { id: rewrite, need: propose, text: "One page on this site reads wrong to me and I know exactly how I'd rewrite it. Where do I send the new text?" } diff --git a/src/build.rs b/src/build.rs index ff7f763..e75b0b5 100644 --- a/src/build.rs +++ b/src/build.rs @@ -126,6 +126,19 @@ pub struct PlanNeed { /// Groups that see these cases on their desk and move nothing. #[serde(default)] pub watched_by: Vec, + /// The event name a stage is entered by, where it must not be the + /// builder's default (the stage's own name; `received` for the + /// first). A live site replays its records by event name, so a + /// rebuild of a site that already holds records has to keep the + /// names its aggregates.yaml has, or every record vanishes from + /// its desk. + #[serde(default)] + pub events: BTreeMap, + /// Who moves cases that no desk button moves - an automation + /// listening on the bucket's events. Written into aggregates.yaml + /// for the reader, as portal's `attended_by` annotation. + #[serde(default)] + pub attended_by: Option, pub collects: Vec, /// How many required fields this person will tolerate: what the /// business said about them, not what the form happens to ask. @@ -253,6 +266,14 @@ impl Plan { bail!(at(&format!("{state:?} is not one of its stages"))); } } + for (state, event) in &need.events { + if !need.stages.contains(state) { + bail!(at(&format!("events names {state:?}, which is not one of its stages"))); + } + if !is_id(event) { + bail!(at(&format!("event {event:?} for {state:?} must be ASCII lowercase with - or _"))); + } + } for state in &need.success { if !need.done_when.contains(state) { bail!(at(&format!("success {state:?} is not one of done_when"))); @@ -303,8 +324,10 @@ impl Plan { || other.moves_by != first.moves_by || other.watched_by != first.watched_by || other.handled_by != first.handled_by + || other.events != first.events + || other.attended_by != first.attended_by { - bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable and who moves and watches it", first.id, other.id); + bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by and who moves and watches it", first.id, other.id); } } } @@ -364,6 +387,19 @@ pub fn machine(need: &PlanNeed) -> BTreeMap> { moves } +/// The event that enters a stage: what the plan says, else the stage's +/// own name, with the first stage entered by `received` (or `submitted` +/// when a stage is itself called received). +pub fn event_for(need: &PlanNeed, stage: &str) -> String { + if let Some(event) = need.events.get(stage) { + return event.clone(); + } + if stage == need.stages[0] { + return if need.stages.iter().any(|st| st == "received") { "submitted" } else { "received" }.to_string(); + } + stage.to_string() +} + /// The stages a case goes through, as one sentence: "It is received, /// then surveyed, then quoted, and ends won or lost." fn stages_sentence(need: &PlanNeed) -> String { @@ -780,14 +816,16 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result>(), + "initial": &need.stages[0], + "states": need.stages.iter().map(|st| (st.clone(), json!({ "event": event_for(need, st) }))).collect::>(), "transitions": machine(need), - }) + }); + if let Some(who) = &need.attended_by { + aggregate["attended_by"] = json!(who); + } + aggregate }).collect::>() }), ); @@ -1180,10 +1218,22 @@ mod tests { lands_in: "b".into(), handled_by: "g".into(), stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable), moves_by: BTreeMap::new(), watched_by: vec![], + events: BTreeMap::new(), attended_by: None, collects: vec![], max_fields: 3, } } + #[test] + fn a_live_sites_event_names_survive_a_rebuild() { + let mut n = need(&["open", "invited", "active", "departed", "declined"], &["departed", "declined"], &["departed"], &[]); + assert_eq!(event_for(&n, "open"), "received"); + assert_eq!(event_for(&n, "active"), "active"); + n.events = [("open", "applied"), ("active", "onboarded")].iter().map(|(s, e)| (s.to_string(), e.to_string())).collect(); + assert_eq!(event_for(&n, "open"), "applied"); + assert_eq!(event_for(&n, "active"), "onboarded"); + assert_eq!(event_for(&n, "invited"), "invited"); + } + #[test] fn the_machine_walks_the_line_and_can_be_turned_down_anywhere() { let m = machine(&need(&["open", "surveyed", "quoted", "won", "lost"], &["won", "lost"], &["won"], &[]));