Files
meguri/docs/proposals/workspace-templates.md
T
ab a47c4a0398
Checks / test (push) Failing after 12m20s
Init
2026-09-24 14:35:05 +03:00

15 KiB

Proposal: workspace data and schedule templates

This is a design-rationale document. Current behavior is defined in product.md and architecture.md: workspace is the sole scope, with no nested teams or cross-workspace bindings. Historical sketches below are not an implementation contract.

Status: the first implementation is available. See product.md for the maintained contract. This document preserves the broader design rationale; reusable parameterized template libraries, derived variables, AND/OR predicate groups, and separate row-level grants remain future work.

Implemented: arbitrary typed trees, stable list/dictionary entry IDs, up to four nested iterations, one optional predicate per rule, constant/variable/item/key/lookup/output bindings, text prefixes/suffixes, ordered priority coverage and other schedule outputs, policies, routing, debounced server validation, impact preview, and atomic activation of variables and generated configuration within one workspace. Edit/activation uses EditSchedules permission in that workspace. Singleton outputs may be shared between rules; iterated outputs stay scoped to their row. Limits are 200 schedules, 200 policies, and 500 routes; a 30-project matrix is covered by regression tests.

Intended workflow

A workspace member with the appropriate grants creates their own named variables and builds reusable rules from visual blocks. Neither variable names nor record fields are fixed to projects/primary/secondary. For example, one person can define a shared list of on-call emails, a team-lead value, and a dictionary mapping each project to a different ordered list of responsible people. Another team can model regions, services, or shifts with different fields. The product supplies types and operations, not a mandatory project schema.

In the UI there are three views: Variables, Rules, and Preview. Lists are editable rows; dictionaries and records are tables/forms with user-defined columns. Rules contain “For each”, “If”, and “Create …” blocks. Each field offers “Fixed value” or “Use variable”; the picker shows compatible variables and fields of enclosing loop items. A breadcrumb such as “Current project → Responsible people” replaces an expression to type. List selection can choose the whole list, first person, or person at a specified position; dictionary selection can look up a key from another variable/current item. Missing positions/keys require an explicit fallback or produce a row-specific validation error. A rule can be saved as a reusable template, but an administrator does not have to prebuild every template for users.

Changing an input automatically regenerates the draft preview, showing resource counts and errors beside the originating row/block. Applying activates one consistent revision and queues publication. Automatic draft calculation must not publish an unreviewed destructive edit. No Rust, JavaScript, Terraform, or textual expression language is required.

Separate project list and responsibility dictionary

The user's concrete input model is two independent variables, represented here as data examples, not a configuration language the UI requires:

{
  "projects": ["A", "B", "C"],
  "responsible": {
    "A": ["alice@example.com", "bob@example.com"],
    "B": ["carol@example.com", "alice@example.com"],
    "C": ["bob@example.com", "carol@example.com"]
  }
}

The visual rule is For each item in projects → Create rotation. Its name binds to the current project; its participants bind to Variable responsible → Entry with key: current project → Entire list. This is semantically responsible[current_project], built by selectors rather than typed syntax. Anchor, shift length, and handover can be fixed values or additional bindings. One schedule block creates A, B, and C rotations with different participant sequences; list order is the rotation order, not an escalation order. Extra escalation/routing blocks are optional.

This lookup joins collections by their values, never their row positions. A reordered responsibility dictionary has no effect. Duplicate project values, a missing dictionary key, an empty participant list, repeated participants, or an invalid email fail preview with the project and binding identified; there is no implicit fallback to another project. Unused responsibility entries may be shown as unused but create no schedules. A deliberate “Use default when key is absent” binding is possible; it must be visible in the rule. The entire activation fails on invalid rows rather than publishing a partial matrix.

Generated schedule identity follows the stable entry ID in the iterated project list and the create block ID. Editing/reordering the participant list changes future base ownership without recreating that schedule; it never resets the stored rotation anchor or drops applied replacements. Renaming a project keeps its hidden entry ID, but the user must update the dictionary lookup key as part of the same valid draft. Tests must distinguish reordering projects, reordering dictionary entries, and reordering a project's participants: only the last changes that rotation's ownership sequence.

Another supported layout is a single table of records. It is optional: users never have to merge the separate variables above into this format.

Stable key Project name Primary Secondary Handover Target team
first First Alice Bob 11:00 Platform
second Second Carol Alice 09:00 Platform

The column types refer to real people, teams, times, and other resources. A dropdown selects references; a free-form name is not an identity. A useful starter type is “Person”: an existing workspace user or an email contact without login access. Plain strings and lists of strings are still allowed. When used as recipients, email strings need an explicit validated conversion to people; non-email strings are not guessed to be usernames. The team can edit allowed cells and add/remove rows without editing the rule structure.

For the minimal dictionary example, a single block can iterate projects, where each entry is a project key and its ordered list of people. Inside it, “Create rotation” takes its title from the current key and its participants from the current value; a second “Create rotation” takes the shared team lead as its only participant; “Create escalation” refers to these two outputs; “Create routing rule” matches the current project and points at that escalation. Thirty dictionary entries then produce thirty connected groups from the same four create blocks. If each project needs separate primary/secondary participant lists, change the entry to a record with those fields and bind two rotation blocks to them. Both are data models the user can choose.

Shared resources can be created outside a loop and referenced inside it, for example one single-participant rotation for the team lead instead of thirty identical rotations. Multiple rules consuming the same dictionary keep independent instance/output identities and explicit precedence; a lookup alone does not create a resource.

“Create alert” is deliberately not an action here: the generated routing/escalation configuration tells the external provider how to process alerts. Meguri continues to own configuration, not incident delivery.

Four boundaries

  1. Workspace data. Versioned, typed values: string, boolean, number, date, local time, duration, working interval, person/team/schedule reference, list, record, and dictionary. Time-of-day, duration, and timestamp are distinct types. A local time needs an explicit workspace/interval IANA zone before it can identify a schedule boundary; user display timezone is never an input. “Dictionary” means a declared schema or map<string, T>, not arbitrary unvalidated JSON. Secrets remain in deployment secret storage.
  2. Rules, templates, and bindings. Persist a versioned, typed block tree with stable block IDs: ForEach(collection, item), If(predicate), and Create(kind, fields). Value nodes are constants, variable references, loop-item/record fields, dictionary lookup, explicit default, name formatting, or references to a create block's output. A picker creates these nodes; users do not write the internal representation. Initial predicates can be equality, contains, and empty/not-empty with AND/OR. Each create block is a stable output slot (schedule, escalation, route). References use IDs and loop keys rather than interpolated display names. Reusable templates parameterize these blocks; an instance binds values and allowed target teams. Renaming a variable does not break references.
  3. Pure expansion and validation. Resolve source variables and any explicitly derived values as an acyclic dependency graph, expand bounded loops into ordinary domain objects, resolve output references, then run existing schedule/dependency/provider-capability validators. Nested collection iterations may use outer fields; a Cartesian product is an explicit operation with a visible output count. Scope checking prevents a reference to an item outside its loop; referencing a conditional output requires compatible guards or an explicit fallback. No arbitrary code, eval, recursive templates, network calls, clock-dependent expressions, or unbounded loops. Reject cycles, invalid types, duplicate keys, missing identities, illegal grants, excessive nesting, and output limits before activation. Freedom to compose variables and blocks does not require a general-purpose programming language.
  4. Activation and publication. Materialize the candidate team documents and a diff of create/update/remove operations. Show affected teams and dependent policies, and preserve an expansion manifest. After review, compare all input revisions and atomically persist the source revision, affected team snapshots, history, audit, and outbox jobs. External providers receive the same canonical publication contract as today. External writes can still partially fail; report their status separately from activation.

Source of truth and stable identity

The source is the active combination of variable revisions, template versions, instance bindings, manual resources, and approved replacements. Generated team documents are immutable derived snapshots, never an independently editable second source of truth. Keep the exact inputs/manifest for reproducing a past expansion without loading today's variables.

Generated identity is based on (instance UUID, stable loop-entry ID path, output block ID), for example via namespaced UUIDs. Renaming a project, variable, dictionary key, or reordering the table must not recreate schedules, lose provider mappings, reset a rotation anchor, or orphan replacement history. Every output records its source instance, entry path, block, and version. Lists and dictionaries have hidden persistent entry IDs separate from labels, keys, and ordinal positions. Dictionary keys remain unique for lookup; changing a referenced lookup key requires validation, while generated identity stays stable. Copying a block/entry deliberately creates a new ID. Rotation anchors are explicit stored inputs or creation-time values preserved in the manifest, never recomputed from today's date.

Generated objects show “Managed by template … / project …” and link to their source row. Direct edits redirect to the matrix or template. Permit only explicitly declared per-row parameters. If later needed, “Detach” copies the effective configuration into manual ownership with a recorded identity/ownership transition; it is not a silent local patch that the next expansion overwrites.

Deletions, replacements, and rule order

Removing a row previews every removed output and its inbound references. References from surviving/manual resources require an explicit replacement or edit; never silently remove unrelated policies. Current/future approved replacements block destructive changes to their schedule. Pending swaps are cancelled only as an explicit part of the reviewed activation. Historical snapshots remain intact. Changing participants or anchors warns about changed future owners and preserves applied overrides using existing engine rules.

Order is semantic because routing uses first match. A template instance owns an ordered block of generated rules, with declared order within each row and a stable order of rows. Drag the block among manual rules; edit row/template order at its source. Do not derive priority from dictionary iteration, UUID ordering, or names. Cross-instance precedence is an explicit workspace/team setting.

Permissions and multi-team changes

Separate rights to create/edit variables and their schema, edit data rows, build rules/templates, and activate changes. These grants can be delegated to team members, not only workspace administrators. Shared templates can restrict which columns/rows and target teams editors manage. A row cannot name an arbitrary tenant or team. Scheduling a person still does not grant them access. Workspace-wide data is intentionally readable to its workspace audience; never place team-private data in a globally readable variable.

Editing shared inputs produces a draft. Activation requires authority over every affected team, or approvals from those teams before one activation; do not partly activate an input revision. The diff lists the complete impact even when one row looks harmless. Source and team revision checks reject stale approvals. Keep activation local and bounded; no database write lock spans provider HTTP calls.

Small first version

Start with user-defined primitive variables, people/email lists, records and dictionaries; constant/variable/current-item bindings; one for each collection; schedule + escalation + route create blocks; stable identities; preview/diff; and atomic activation for one target team. A starter “Project on-call” template is a convenience, not a special hard-coded model. This first version already handles different responsible lists per project plus shared team-lead/default values. Add conditional/nested iterations and reusable parameterized templates next, then explicit Cartesian products and multi-team activation. Reuse the existing engine, provider contract, and outbox. Do not start by prescribing a fixed table or introducing a programming language.

Before rollout, size domain quotas for this use case: the current limit is 50 schedules per team, so 30 projects with two schedules each already exceeds it. Establish tested resource/expansion budgets and provider-specific limits, show counts before activation, and reject excess atomically. Raising a cap alone is insufficient without measuring engine, history size, and publication cost.

Required tests: deterministic expansion; stable identity across rename/reorder; matrix limits and key collisions; type/reference/cycle validation; scope enforcement; deleting rows with inbound references or approved replacements; stale previews and concurrent activation; history reproducibility; no changes to unrelated manual resources; routing precedence; retrying publication of the same expansion.