build: a plan keeps a live site's event names, and names its automations
`events:` on a need overrides the event a stage is entered by. Portal replays records by event name, so rebuilding a site that already holds records has to keep what its aggregates.yaml says, or the desks empty. `attended_by:` writes portal's annotation for a bucket an automation moves. plans/uhhm.yaml: uhhm.no from a plan - the five live buckets with their names, plus trials (the trainer tried on a client's business, approved by hand) and custody_requests (UHHM as redoal's custodian, a desk of its own). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
93a6fc0e10
commit
04a6eeea85
@@ -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
|
||||
|
||||
|
||||
+181
@@ -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?" }
|
||||
+57
-7
@@ -126,6 +126,19 @@ pub struct PlanNeed {
|
||||
/// Groups that see these cases on their desk and move nothing.
|
||||
#[serde(default)]
|
||||
pub watched_by: Vec<String>,
|
||||
/// 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<String, String>,
|
||||
/// 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<String>,
|
||||
pub collects: Vec<Field>,
|
||||
/// 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<String, Vec<String>> {
|
||||
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<String, Value>) -> anyhow::Result<BT
|
||||
"aggregates.yaml".into(),
|
||||
json!({ "aggregates": plan.by_bucket().into_iter().map(|(bucket, needs)| {
|
||||
let need = needs[0];
|
||||
let initial = &need.stages[0];
|
||||
let first_event = if need.stages.iter().any(|st| st == "received") { "submitted" } else { "received" };
|
||||
json!({
|
||||
let mut aggregate = json!({
|
||||
"bucket": bucket,
|
||||
"initial": initial,
|
||||
"states": need.stages.iter().map(|st| (st.clone(), json!({ "event": if st == initial { first_event } else { st.as_str() } }))).collect::<Map<_, _>>(),
|
||||
"initial": &need.stages[0],
|
||||
"states": need.stages.iter().map(|st| (st.clone(), json!({ "event": event_for(need, st) }))).collect::<Map<_, _>>(),
|
||||
"transitions": machine(need),
|
||||
})
|
||||
});
|
||||
if let Some(who) = &need.attended_by {
|
||||
aggregate["attended_by"] = json!(who);
|
||||
}
|
||||
aggregate
|
||||
}).collect::<Vec<_>>() }),
|
||||
);
|
||||
|
||||
@@ -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"], &[]));
|
||||
|
||||
Reference in New Issue
Block a user