🐸 LifeOS

Building a Strand — Developer Guide

For people first, language models second. Everything a strand can do, with an example of each. Current as of 2026-08-22. The contract is the TypeScript in src/lib/types.ts; this page explains it.


1. What a strand is

LifeOS is a personal secretary. A strand is one piece of life admin it looks after for you: your car's MOT, your pet's worming, birthdays, a book club's subs, a darts league.

A strand is a definition (what to ask the user, what tasks and events to create, how to show them) that a user installs with their own config (their car's registration, their pets). The engine turns definition + config into dated tasks and events in the user's dashboard, and keeps them fresh.

There are two kinds:

Declarative strand Native strand
What it is A JSON document Shipped code behind a manifest
Who writes it Anyone — the wizard, a JSON file, or an LLM The platform team
Examples Car Care, Birthdays, Match Organiser League (Toads, Darts, Pool, Chess), Club (Book Club, Street)
Runs in The strand engine, sandboxed by this contract Its own routes, tables and server code
Installing creates Tasks and events for the user (or a group) A context — a league or a club — with a public page, people, billing and email

This guide is about declarative strands. Native strands are covered at the end (section 9) so you know where the line is.


2. The smallest possible strand

A strand that asks for one date and creates one task.

{
  "id": "passport-renewal",
  "name": "Passport",
  "description": "Never get caught by the six-month validity rule.",
  "icon": "🛂",
  "thread": "stability",
  "version": "1.0.0",
  "pattern": "task_generator",
  "configSchema": [
    { "id": "expiryDate", "label": "Passport expiry date", "type": "date", "required": true }
  ],
  "taskTemplates": [
    {
      "id": "renew",
      "title": "Renew passport",
      "description": "Many countries need six months left on your passport. Renew before this date.",
      "priority": "high",
      "healthImpact": -20,
      "dueDate": { "source": "config.expiryDate", "offset": { "months": -7 } }
    }
  ]
}

That is a complete, installable strand. Everything else in this guide is optional.


3. The top-level fields

Field Required Meaning
id yes Slug, lower-case with hyphens. Official strands live at /strands/<id>; yours at /strands/@yourhandle/<id>.
name yes Shown in the catalogue.
description yes One or two sentences.
icon yes An emoji ("🚗") or a Font Awesome class ("fa-solid fa-car").
thread yes Which life thread this feeds: health, social, love, stability, career, growth.
version yes Semver, "1.0.0". Bump it when you change the definition (see §7).
pattern yes How the engine treats it — §4.
targetType no individual (default), group, or both. Group strands install into a group and can assign tasks to members (§6).
configSchema yes The questions asked at install (§5.1). May be [].
taskTemplates yes Tasks to generate (§5.2). May be [].
eventTemplates no Calendar events to generate (§5.3).
forms no Forms the user can open later to add items or events (§5.4).
uiLayout no How the strand's dashboard widget and detail page look (§5.5).
migrations no How to move a user's config between versions (§7).
fees no Member fees a group strand declares; the group's owner collects them (§6.3).
native, presets Native strands only (§9). Leave these out.

4. Patterns

The pattern tells the engine and the UI what kind of thing this is.

Pattern Use it for Typical shape
task_generator Dated chores and deadlines — MOT, boiler service, prescription reorders configSchema with dates + taskTemplates with dueDate rules and recurrence
list_with_tasks A list of things, each generating tasks — birthdays, appliances, pets An array config field + taskTemplates using forEach
habit_tracker Something done on a cadence with streaks — workouts, practice Daily/weekly recurrence; healthImpact rewards completion
goal_tracker A target with progress — savings pot, race training Config with a target + uiLayout with a progress_ring
event_organiser Dated events people RSVP to — match nights, game nights (group) forms with create_event; uiLayout with event_list

Patterns don't unlock different JSON — every strand may use every field below. They set defaults and tell the wizard and widgets how to present things.


5. Every capability, with an example

5.1 Config schema — what you ask the user

Each entry is a field on the install form. Grouped into wizard steps with step.

{
  "id": "serviceIntervalMonths",
  "label": "Service interval",
  "type": "select",
  "required": true,
  "default": "12",
  "helpText": "Check your handbook",
  "options": [
    { "value": "6",  "label": "Every 6 months" },
    { "value": "12", "label": "Every 12 months" }
  ]
}

Field types:

type Value stored Notes
text string placeholder supported
textarea string multi-line
number number
date ISO date "2027-03-14" the workhorse for due-date rules
datetime ISO timestamp for events
boolean true/false checkbox
select one of options[].value strings, even for numbers ("12") — the engine parses them when used as offsets
frequency string a select whose values are cadences; pairs with dueDate.type: "frequency"
array array of objects repeating items (pets, people, appliances); define the item's fields in itemSchema; minItems optional
file storage URL accept ("image/*") and bucket ("photos")

An array field is how list strands work:

{
  "id": "pets", "label": "Your pets", "type": "array", "required": true, "minItems": 1,
  "itemSchema": [
    { "id": "name", "label": "Name", "type": "text", "required": true },
    { "id": "species", "label": "Species", "type": "select", "required": true,
      "options": [{ "value": "dog", "label": "Dog" }, { "value": "cat", "label": "Cat" }] },
    { "id": "lastWormed", "label": "Last wormed", "type": "date", "required": false }
  ]
}

5.2 Task templates — the tasks you generate

{
  "id": "worming",
  "title": "Worm {{pet.name}}",
  "description": "Every 3 months for {{pet.species}}s.",
  "priority": "medium",
  "healthImpact": 5,
  "recurrence": { "type": "monthly", "interval": 3 },
  "forEach": "config.pets",
  "as": "pet",
  "condition": { "field": "pet.lastWormed", "exists": true },
  "dueDate": { "source": "pet.lastWormed", "offset": { "months": 3 }, "fallback": { "type": "relative", "days": 7 } }
}
Key Meaning
id Stable per template; the engine uses it to regenerate without duplicating.
title, description May contain {{config.field}} placeholders anywhere, and {{item.field}} inside forEach. An empty optional field leaves its placeholder out and tidies the separator next to it.
priority low · medium · high
healthImpact Integer. Positive = completing it helps the thread's health; negative = missing it hurts. Range ±20 is sensible.
recurrence { "type": "weekly", "interval": 2 } — when completed, the next occurrence is created. type and interval may be references ("interval": "bin.every"), or give a cadence map: { "source": "person.cadence", "map": { "weekly": { "type": "weekly", "interval": 1 }, "monthly": { "type": "monthly", "interval": 1 } } }. Unresolvable → the task is one-off.
forEach + as Make one task per item of an array config field. forEach: "config.pets", as: "pet"{{pet.name}} and pet.lastWormed are available.
condition Only generate when true (§5.2.2).
dueDate When it's due (§5.2.1).
groupAssignment Group strands only — who gets it (§6.2).

5.2.1 Due-date rules

The engine evaluates dueDate at install (and regeneration) time. Four forms:

{ "type": "relative", "days": 30 }

30 days from now. Also "months": 6.

{ "source": "config.motDueDate" }

On the date in that config field. source can point at a config field (config.x) or a forEach item field (pet.x).

{ "source": "config.motDueDate", "offset": { "days": -21 }, "fallback": { "type": "relative", "days": 7 } }

Three weeks before the MOT; if the user didn't give a date, a week from now. Offsets may be negative. An offset can reference config: "offset": { "months": "config.serviceIntervalMonths" }.

{ "type": "frequency", "source": "config.washFrequency",
  "map": { "weekly": { "days": 7 }, "fortnightly": { "days": 14 }, "monthly": { "days": 30 } },
  "fallback": { "type": "relative", "days": 14 } }

Look up the user's chosen cadence and add that offset.

If nothing matches, the engine falls back to 30 days from now — so always give a fallback for optional dates.

{ "date": "2026-04-05" }

A fixed calendar date. If the template recurs yearly or monthly, a date that has passed rolls forward to its next occurrence.

{ "fn": "everyNWeeksFrom", "args": { "from": "bin.next", "weeks": "bin.every" }, "offset": { "days": -1 } }

A function from the standard library, for the dates the DSL can't say. Args are literals or references. Offsets apply after. The library:

fn args Returns (always the next matching day on or after today)
annualOn month, day the next e.g. 31 January
ukTaxYearEnd the next 5 April
nextWeekday weekday ("saturday" or 0–6), from? first such weekday on or after from (default today)
nextNthWeekdayOfMonth n (1–4, or -1 for last), weekday e.g. the first Tuesday — this month if still to come, else next month
everyNWeeksFrom from (date), weeks the next date in the series from, from+N weeks, …

All dates are the user's local calendar days: "2026-09-12" is the 12th wherever they are, and a task is due at the end of its day.

Event dates (§5.3) accept fn/args too.

5.2.2 Conditions

{ "field": "config.washFrequency", "notEquals": "never" }
{ "field": "pet.lastWormed", "exists": true }
{ "field": "config.hasGarden", "equals": true }

One of exists, equals, notEquals. exists: true means non-empty. Values compare exactly ("12" is not 12).

5.3 Event templates — calendar entries

Where a task is a to-do, an event is a moment: a birthday, an anniversary, an appointment. Events can carry reminder tasks that appear N days before.

{
  "id": "pet-birthday",
  "title": "{{pet.name}}'s birthday",
  "eventType": "birthday",
  "allDay": true,
  "forEach": "config.pets",
  "as": "pet",
  "date": { "source": "pet.birthday", "recurrence": "yearly" },
  "reminderTasks": [
    { "title": "Get {{pet.name}} a treat", "priority": "low", "daysBefore": 3 }
  ]
}
Key Meaning
eventType birthday · anniversary · appointment · reminder · other
date { "source": "config.x" }, { "date": "2026-12-25" } fixed, or { "fn": "ukTaxYearEnd" } (§5.2.1), plus recurrence: "yearly" | "monthly" | "once" and optional offset. A recurring date is rolled forward to its next occurrence at install — a birthday entered as 1980-03-14 becomes the coming 14 March, and reminders count back from that.
allDay, duration, busy, location calendar details (duration: { "hours": 1, "minutes": 30 })
reminderTasks[] each becomes a task daysBefore the event
forEach/as, condition, healthImpact as for tasks

5.4 Forms — letting the user add things later

Forms open from the strand's page (via uiLayout buttons) and write back into the config or create events/tasks.

{
  "id": "add-appliance",
  "title": "Add an appliance",
  "mode": "create",
  "entityType": "appliance",
  "fields": [
    { "id": "name", "label": "What is it?", "type": "text", "required": true },
    { "id": "warrantyEnds", "label": "Warranty ends", "type": "date", "required": true }
  ],
  "submitLabel": "Add",
  "onSubmit": { "type": "append_to_config", "target": "appliances", "regenerateTasks": true }
}

onSubmit.type:

Type Effect
append_to_config Push the submitted object onto the array config field named by target; regenerateTasks: true re-runs the task templates so the new item gets its tasks.
update_config_item Edit one item in that array (mode: "edit").
create_event Create a calendar event from the fields (title, date, location…); inviteGroupMembers: true invites the group.
create_task Create a one-off task.

5.5 UI layout — how it looks

Optional. Without it the strand gets a sensible default (health ring + next tasks). With it you compose widgets for the dashboard card and the detail page from these components:

type What it shows
card container with optional title and children
tabs { "tabs": [{ "label", "content" }] }
stat label + value (may use {{config.field}}), format: "date" | "number" | "text"
stats_grid several stats in columns
progress_ring value/max (both may reference config) — goal trackers
task_list filter: "strand" | "all" | "overdue"
next_tasks limit, showDueDate, emptyMessage
strand_health showRing, showStats, stats: ["pending","completed","overdue"]
entity_list rows from an array config field: source: "config.pets", display: { title: "{{item.name}}", subtitle }, row actions (open_form, delete), emptyState
text content, variant: "heading" | "body" | "caption"
action_button label, icon, variant, action
event_list, next_events event organisers: filter: "upcoming" | "past", RSVP controls

Actions: { "type": "open_form", "formId": "add-appliance" } or { "type": "rsvp", "eventId": "…", "status": "going" }.

"uiLayout": {
  "dashboard": { "type": "card", "children": [
    { "type": "strand_health", "showRing": true },
    { "type": "next_tasks", "limit": 3, "showDueDate": true }
  ]},
  "detail": { "type": "tabs", "tabs": [
    { "label": "Appliances", "content": { "type": "entity_list", "source": "config.appliances", "entityType": "appliance",
        "display": { "title": "{{item.name}}", "subtitle": "Warranty ends {{item.warrantyEnds}}" },
        "actions": [{ "type": "open_form", "formId": "add-appliance", "label": "Edit" }, { "type": "delete", "label": "Remove" }],
        "emptyState": { "message": "No appliances yet", "actionLabel": "Add one", "action": { "type": "open_form", "formId": "add-appliance" } } } },
    { "label": "Tasks", "content": { "type": "task_list", "filter": "strand" } }
  ]}
}

6. Group strands

Set "targetType": "group" (or "both"). A group strand installs into a group instead of a person; its tasks can be shared out; it may declare fees. (The group UI is currently behind the social feature flag while the platform focuses on leagues and clubs — the engine and contract are live.)

6.1 Config is shared

One config for the group (the rota, the kitty). Installing asks the installer; admins can edit.

6.2 Assigning tasks to members

"groupAssignment": { "mode": "rotate", "rotationKey": "bins" }
mode Who gets the task
everyone one task per member
rotate next member in turn; rotationKey groups templates that share a rota (state lives in config under __rotation)
fixed a role: fixedRole: "owner" | "admin" | "any"
select chosen when the task is created

6.3 Fees — charging members

A group strand may say "this group has a sub". On install the platform creates the matching fee rows for that group; the group's owner is the tenant — they connect their own Stripe and the money goes to them, with the platform taking its fixed percentage. The strand author never handles money.

"fees": [
  { "id": "kitty", "label": "Monthly kitty", "amount_pence": 300, "interval": "month" },
  { "id": "trip",  "label": "Trip deposit",  "amount_pence": 5000, "interval": "one_off" }
]

interval: one_off · month · year. Members see "Pay" in the group; managers see who's paid in People and can mark cash payments. See TOADS-LEAGUE-SPEC.md → platform billing.


7. Versions and migrations

Users install a version. When you publish a new one, they can upgrade; if the config shape changed, give a migration so their data moves across.

"version": "2.0.0",
"migrations": [
  {
    "fromVersion": "1.0.0",
    "description": "Pets became a list",
    "operations": [
      { "type": "wrapInArray", "fields": ["petName", "petSpecies"], "targetField": "pets", "mapping": { "petName": "name", "petSpecies": "species" } },
      { "type": "setDefault", "field": "reminderDays", "value": 7 }
    ]
  }
]

Operations: renameField (from,to) · copyField · wrapInArray · setDefault · removeFields (fields) · transformValue (transform: "lowercase" | "uppercase" | "trim").

Rule of thumb: patch for copy and fixes, minor for new optional fields, major when a migration is needed.


8. Publishing and review

  • Wizard: Strands → Create Custom Strand walks you through config and templates.
  • JSON: official strands are plain JSON (see static/strands/*.json); yours can be too.
  • By an LLM (planned — MVP Phase 2): the MCP server exposes get_strand_schema (this contract as JSON Schema) and draft_strand (submit a definition → validated → saved as a draft for you to review in the wizard).

What validation rejects: unknown top-level keys; missing id/name/thread/version; a thread not in the six; duplicate template or field ids; forEach without as; a source that isn't config.<field> or <as>.<field>; select options without value; a condition with none of exists/equals/notEquals. Fix the message it gives you and resubmit.

Sharing and selling (gated): strands are private to their author until the review/provenance flow ships. The schema already supports a price on a strand paid to its author through their own Stripe account; the catalogue won't show author prices until review, provenance labels and takedown exist.


9. Native strands — where the line is

Some things can't be JSON: a league needs fixtures, two-captain score corroboration, live scores, public tables; a club needs a roster of people who never sign up, subs, invitations. Those are native strands: code modules behind a manifest (src/lib/strands/native/).

The manifest is the contract that keeps them strands rather than separate apps:

interface NativeStrandManifest {
  module: 'league' | 'club';
  displayName; icon; thread; description;
  presets: NativePreset[];                          // "Toads League", "Book Club" — the catalogue entries
  installPath(preset): string;                      // the install form; creates the context
  routes: { public(ctx): string; app(ctx): string };
  capabilities: { tenantFees; people; invitations; rsvp; publicPage };
  dashboardCards(userId): Promise<DashboardCard[]>;
}

Everything a native strand does with people, money and email goes through the same platform primitives a declarative group strand uses: context_people (roster), fees/fee_payments (billing), invitations, messaging, the People surface, RSVPs. That's deliberate — it's what lets "a club" and "a league" look the same in the catalogue and on the dashboard.

To add a native strand: write the manifest, register it in src/lib/strands/native/index.ts, seed it (it appears automatically), build its routes. New native strands are platform releases, not user uploads.


10. Contract summary (for LLMs and quick reference)

One skeleton with every key. Optional keys are marked ?. Delete what you don't use.

{
  "id": "kebab-slug",                       // required
  "name": "Display name",                   // required
  "description": "One or two sentences.",   // required
  "icon": "🧩",                             // required: emoji or "fa-solid fa-…"
  "thread": "stability",                    // required: health|social|love|stability|career|growth
  "version": "1.0.0",                       // required: semver
  "pattern": "task_generator",              // required: task_generator|list_with_tasks|habit_tracker|goal_tracker|event_organiser
  "targetType?": "individual",              // individual|group|both

  "configSchema": [                         // required (may be [])
    { "id": "f", "label": "…", "type": "date", "required": true,
      "placeholder?": "…", "helpText?": "…", "default?": "…", "step?": "Basics",
      "options?": [{ "value": "a", "label": "A" }],          // select / frequency
      "minItems?": 1, "itemSchema?": [ /* fields */ ],      // array
      "accept?": "image/*", "bucket?": "photos" }           // file
  ],

  "taskTemplates": [                        // required (may be [])
    { "id": "t", "title": "… {{item.field}}", "description?": "…",
      "priority": "medium", "healthImpact": 5,
      "recurrence?": { "type": "monthly", "interval": 1 },              // type/interval may be refs ("item.every"),
                      // or a cadence map: { "source": "item.cadence", "map": { "weekly": { "type": "weekly", "interval": 1 } } }
      "forEach?": "config.items", "as?": "item",
      "condition?": { "field": "config.x", "exists": true },  // or equals / notEquals
      "dueDate": { "source?": "config.date", "date?": "2027-04-05",
                   "fn?": "annualOn|ukTaxYearEnd|nextWeekday|nextNthWeekdayOfMonth|everyNWeeksFrom", "args?": { "month": 4, "day": 5 },
                   "offset?": { "days": -7, "months": "config.n" },
                   "type?": "relative|frequency", "days?": 30, "months?": 1,
                   "map?": { "weekly": { "days": 7 } }, "fallback?": { "type": "relative", "days": 7 } },
      "groupAssignment?": { "mode": "rotate", "rotationKey?": "k", "fixedRole?": "owner" } }
  ],

  "eventTemplates?": [
    { "id": "e", "title": "…", "eventType?": "birthday", "allDay?": true,
      "date": { "source?": "config.date", "date?": "2026-12-25", "fn?": "ukTaxYearEnd", "args?": {}, "recurrence?": "yearly", "offset?": { "days": 0 } },
      "duration?": { "hours": 1 }, "busy?": true, "location?": "…", "healthImpact?": 0,
      "forEach?": "config.items", "as?": "item", "condition?": { "field": "…", "exists": true },
      "reminderTasks?": [{ "title": "…", "priority": "low", "daysBefore": 3 }] }
  ],

  "forms?": [
    { "id": "form", "title": "…", "mode": "create", "entityType?": "thing", "fields": [ /* config fields */ ],
      "submitLabel?": "Add",
      "onSubmit": { "type": "append_to_config", "target?": "items", "regenerateTasks?": true, "inviteGroupMembers?": false } }
      // type: append_to_config | update_config_item | create_event | create_task
  ],

  "uiLayout?": { "dashboard?": { "type": "card", "children": [] }, "detail?": { "type": "tabs", "tabs": [] } },
      // components: card, tabs, stat, stats_grid, progress_ring, task_list, next_tasks, strand_health,
      //             entity_list, text, action_button, event_list, next_events

  "migrations?": [{ "fromVersion": "1.0.0", "description": "…",
      "operations": [{ "type": "renameField", "from": "a", "to": "b" }] }],
      // types: renameField, copyField, wrapInArray, setDefault, removeFields, transformValue

  "fees?": [{ "id?": "kitty", "label": "…", "amount_pence": 300, "interval": "month" }]   // group strands only
}

Paths: config.<fieldId> for config; <as>.<fieldId> inside forEach. Placeholders in text: {{config.<fieldId>}} anywhere, {{<as>.<fieldId>}} inside forEach.

Dates are ISO (YYYY-MM-DD); money is integer pence; intervals are one_off|month|year; recurrence types are daily|weekly|monthly|yearly.


11. Worked example — a complete list strand with events, a form and a layout

{
  "id": "appliance-warranties",
  "name": "Appliance Warranties",
  "description": "Register what you own; get warned before each warranty ends and nudged to decide repair-or-replace.",
  "icon": "🔌",
  "thread": "stability",
  "version": "1.0.0",
  "pattern": "list_with_tasks",
  "configSchema": [
    { "id": "appliances", "label": "Appliances", "type": "array", "required": false, "step": "Your appliances",
      "itemSchema": [
        { "id": "name", "label": "Appliance", "type": "text", "required": true, "placeholder": "Washing machine" },
        { "id": "bought", "label": "Bought on", "type": "date", "required": true },
        { "id": "warrantyEnds", "label": "Warranty ends", "type": "date", "required": true },
        { "id": "serviceEvery", "label": "Service every", "type": "select", "required": false, "default": "never",
          "options": [{ "value": "never", "label": "No servicing" }, { "value": "12", "label": "12 months" }, { "value": "24", "label": "24 months" }] }
      ] }
  ],
  "taskTemplates": [
    { "id": "warranty-warning", "title": "{{appliance.name}}: warranty ends soon", "priority": "medium", "healthImpact": -8,
      "forEach": "config.appliances", "as": "appliance",
      "dueDate": { "source": "appliance.warrantyEnds", "offset": { "days": -30 } } },
    { "id": "service", "title": "Service the {{appliance.name}}", "priority": "low", "healthImpact": 4,
      "forEach": "config.appliances", "as": "appliance",
      "condition": { "field": "appliance.serviceEvery", "notEquals": "never" },
      "recurrence": { "type": "monthly", "interval": 12 },
      "dueDate": { "source": "appliance.bought", "offset": { "months": "appliance.serviceEvery" } } }
  ],
  "eventTemplates": [
    { "id": "warranty-end", "title": "{{appliance.name}} warranty ends", "eventType": "reminder", "allDay": true,
      "forEach": "config.appliances", "as": "appliance", "date": { "source": "appliance.warrantyEnds", "recurrence": "once" } }
  ],
  "forms": [
    { "id": "add-appliance", "title": "Add an appliance", "mode": "create", "entityType": "appliance",
      "fields": [
        { "id": "name", "label": "Appliance", "type": "text", "required": true },
        { "id": "bought", "label": "Bought on", "type": "date", "required": true },
        { "id": "warrantyEnds", "label": "Warranty ends", "type": "date", "required": true }
      ],
      "submitLabel": "Add", "onSubmit": { "type": "append_to_config", "target": "appliances", "regenerateTasks": true } }
  ],
  "uiLayout": {
    "dashboard": { "type": "card", "children": [
      { "type": "strand_health", "showRing": true, "stats": ["pending", "overdue"] },
      { "type": "next_tasks", "limit": 3, "showDueDate": true, "emptyMessage": "Nothing due" },
      { "type": "action_button", "label": "Add appliance", "variant": "secondary", "action": { "type": "open_form", "formId": "add-appliance" } }
    ]},
    "detail": { "type": "tabs", "tabs": [
      { "label": "Appliances", "content": { "type": "entity_list", "source": "config.appliances", "entityType": "appliance",
          "display": { "title": "{{item.name}}", "subtitle": "Warranty ends {{item.warrantyEnds}}" },
          "actions": [{ "type": "delete", "label": "Remove" }],
          "emptyState": { "message": "Nothing registered yet", "actionLabel": "Add one", "action": { "type": "open_form", "formId": "add-appliance" } } } },
      { "label": "Tasks", "content": { "type": "task_list", "filter": "strand" } }
    ]}
  }
}

12. Checklist before you publish

The platform validates every definition on the way into the catalogue (validateStrandDefinition in src/lib/strands/validate.ts) — seed, publish and the catalogue lint test all refuse a definition with errors, and they tell you exactly which field is wrong. Run npx vitest run src/lib/strands locally to see the same messages.

  • Every id is unique within its list and never changes between versions.
  • Every optional date has a fallback.
  • select values are strings; numeric offsets that reference them still work (the engine parses).
  • forEach templates use {{as.field}} and as.field paths, not config.….
  • healthImpact is modest and signed sensibly.
  • You've installed it yourself and looked at the tasks it made on a dashboard.
  • If the config shape changed since the last version, there's a migration and a major bump.