diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 94e4d5c..bd6e845 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,23 +12,7 @@ { "name": "allium", "description": "Velocity through clarity.", - "source": { - "source": "github", - "repo": "juxt/allium", - "ref": "v3.3.0" - }, - "skills": [ - "./skills/allium", - "./skills/distill", - "./skills/elicit", - "./skills/propagate", - "./skills/tend", - "./skills/weed" - ], - "agents": [ - "./agents/tend.md", - "./agents/weed.md" - ] + "source": "./plugins/allium" }, { "name": "chalk", diff --git a/plugins/allium/.aider.conf.yml b/plugins/allium/.aider.conf.yml new file mode 100644 index 0000000..eed0b76 --- /dev/null +++ b/plugins/allium/.aider.conf.yml @@ -0,0 +1 @@ +lint-cmd: "./hooks/allium-lint.sh" diff --git a/plugins/allium/.claude-plugin/plugin.json b/plugins/allium/.claude-plugin/plugin.json new file mode 100644 index 0000000..967b71a --- /dev/null +++ b/plugins/allium/.claude-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "name": "allium", + "version": "3.3.0", + "description": "Velocity through clarity.", + "author": { + "name": "JUXT", + "url": "https://juxt.pro" + }, + "homepage": "https://juxt.github.io/allium/", + "repository": "https://github.com/juxt/allium", + "license": "MIT", + "keywords": ["specification", "behaviour", "behavior", "domain-modelling", "BDD", "property-based-testing", "generative-tests"], + "skills": [ + "./skills/allium", + "./skills/distill", + "./skills/elicit", + "./skills/propagate", + "./skills/tend", + "./skills/weed" + ], + "agents": [ + "./agents/tend.md", + "./agents/weed.md" + ] +} diff --git a/plugins/allium/.claude/rules/allium.md b/plugins/allium/.claude/rules/allium.md new file mode 100644 index 0000000..a57ed38 --- /dev/null +++ b/plugins/allium/.claude/rules/allium.md @@ -0,0 +1,55 @@ +--- +globs: "**/*.allium" +--- + +# Allium language + +Allium is a behavioural specification language for describing what systems should do, not how they do it. It has no compiler or runtime; LLMs and humans interpret it directly. + +## File structure + +Every `.allium` file starts with `-- allium: N` where N is the current language version. Sections follow a fixed order: use declarations, given, external entities, value types, contracts, enumerations, entities and variants, config, defaults, rules, invariants, actor declarations, surfaces, deferred specifications, open questions. Omit empty sections. Section headers use comment dividers (`----`). + +## Syntax distinctions that trip up models + +**`with` vs `where`** — `with` declares relationships (`slots: InterviewSlot with candidacy = this`), `where` filters projections (`confirmed_slots: slots where status = confirmed`). Swapping them is a type error. + +**`transitions_to` vs `becomes`** — Both are trigger types. `transitions_to` fires when a field changes to a value from a different value, not on initial creation. `becomes` fires both on creation with that value and on transition to it. Use `becomes` when the rule should apply regardless of how the entity reached the state. + +**Capitalised vs lowercase pipe values** — Capitalised values are variant references (`kind: Branch | Leaf`), lowercase values are enum literals (`status: pending | active`). The validator checks that capitalised names correspond to `variant` declarations. + +**`.created()` for entity creation** — New entities are expressed as `EntityName.created(field: value)` in `ensures` clauses. Variant instances must use the variant name, not the base entity. + +**Temporal triggers need `requires` guards** — Temporal triggers fire once when the condition becomes true, but without a guard they can re-fire if the entity remains in a qualifying state. Always pair with `requires: token.status = active` or equivalent to prevent re-firing. + +**`now` evaluation model** — In derived values, `now` re-evaluates on each read (volatile). In `ensures` clauses, `now` is bound to rule execution timestamp (snapshot). In temporal triggers, `now` is the evaluation timestamp with fire-once semantics. + +**Naming conventions** — PascalCase for entities, variants, rules, triggers, actors, surfaces, contract names, invariant names. snake_case for fields, config parameters, derived values, enum literals. + +**`contracts:` clause vs `exposes`/`provides`** — `exposes` and `provides` are colon-delimited clause lists (data visibility, available actions). `contracts:` uses `demands`/`fulfils` modifiers referencing module-level `contract` declarations (`contracts: demands Codec, fulfils EventSubmitter`). Contracts are always declared at module level with `contract Name { ... }`. + +**`@` annotation sigil** — The `@` prefix marks prose annotations: constructs whose structure (name, placement, uniqueness) the checker validates, but whose prose content it does not evaluate. Three annotation keywords exist: `@invariant` (named prose assertion in contracts), `@guidance` (non-normative advice in contracts, rules, surfaces) and `@guarantee` (named prose assertion in surfaces). `@guidance` must appear after all structural clauses and after all other annotations. When a prose annotation is promoted to an expression-bearing form, the `@` is dropped and a `{ expr }` body is added. + +**`@invariant` vs `invariant Name { }` vs `@guarantee`** — `@guarantee` is a surface-level prose assertion about the boundary as a whole. `@invariant` is a named prose assertion scoped to a contract. `invariant Name { expression }` (no `@`, braces) is an expression-bearing assertion at top-level or entity-level scope. They are distinct constructs. The `@` sigil marks prose annotations whose structure the checker validates but whose content it does not evaluate. + +**Contract contents** — Only typed signatures and `@`-prefixed annotations (`@invariant`, `@guidance`) are permitted inside contracts. Type declarations (entity, value, enum, variant) must be declared at module level and referenced by name. + +## Anti-patterns + +**Implementation leakage** — Specs describe observable behaviour, not databases, APIs or services. If a field name implies a storage mechanism (`database_id`, `api_response`), rephrase it. + +**Missing temporal guards** — Every temporal trigger (`field <= now`, `field + duration <= now`) needs a `requires` clause to prevent infinite re-firing. + +**Magic numbers** — Variable values belong in `config` blocks, not hardcoded in rules. Use `config.max_attempts` rather than literal `5`. + +**Implicit lambdas** — Collection operations use explicit parameter syntax: `interviewers.any(i => i.can_solo)`, not `interviewers.any(can_solo)`. + +**Dot-method black box functions** — Dot-method syntax on collections is reserved for built-in operations (`.count`, `.any()`, `.all()`, `.first`, `.last`, `.unique`, `.add()`, `.remove()`). Domain-specific collection operations use free-standing black box function syntax with the collection as the first argument: `filter(events, e => e.recent)`, not `events.filter(e => e.recent)`. + +**Overly broad enums** — If an inline enum appears on multiple fields that need comparison, extract a named `enum`. Inline enums are anonymous and cannot be compared across fields. + +**Inline enum comparison** — Two inline enum fields cannot be compared even if they share the same literals. The checker reports an error. Extract a named enum when values need comparison across fields. + +## Reference + +See `skills/allium/references/language-reference.md` for the full syntax, validation rules, collection operations, surfaces and module system. diff --git a/plugins/allium/.codex-plugin/plugin.json b/plugins/allium/.codex-plugin/plugin.json new file mode 100644 index 0000000..2a14fcc --- /dev/null +++ b/plugins/allium/.codex-plugin/plugin.json @@ -0,0 +1,41 @@ +{ + "name": "allium", + "version": "3.3.0", + "description": "Velocity through clarity.", + "author": { + "name": "JUXT", + "url": "https://juxt.pro" + }, + "homepage": "https://juxt.github.io/allium/", + "repository": "https://github.com/juxt/allium", + "license": "MIT", + "keywords": [ + "specification", + "behaviour", + "behavior", + "domain-modelling", + "BDD", + "property-based-testing", + "generative-tests" + ], + "skills": "./skills/", + "interface": { + "displayName": "Allium", + "shortDescription": "Behavioural specifications for agentic engineering", + "longDescription": "Write, maintain, review and propagate Allium behavioural specifications alongside implementation work.", + "developerName": "JUXT", + "category": "Coding", + "capabilities": [ + "Read", + "Write", + "Interactive" + ], + "websiteURL": "https://juxt.github.io/allium/", + "defaultPrompt": [ + "Draft an Allium spec for this feature.", + "Review this .allium spec for gaps.", + "Generate test obligations from this spec." + ], + "screenshots": [] + } +} diff --git a/plugins/allium/.cursor/hooks.json b/plugins/allium/.cursor/hooks.json new file mode 100644 index 0000000..cde96f3 --- /dev/null +++ b/plugins/allium/.cursor/hooks.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "hooks": { + "afterFileEdit": [ + { + "command": "node ./hooks/allium-check.mjs" + } + ] + } +} diff --git a/plugins/allium/.github/agents/tend.agent.md b/plugins/allium/.github/agents/tend.agent.md new file mode 100644 index 0000000..254a38c --- /dev/null +++ b/plugins/allium/.github/agents/tend.agent.md @@ -0,0 +1,95 @@ +--- +name: tend +description: "Tend the Allium garden. Use when the user wants to write, edit, update, add to, improve, clarify, refine, restructure, fix or migrate Allium specs. Covers adding entities, rules, triggers, surfaces and contracts, fixing syntax or validation errors, renaming or refactoring within specs, migrating specs to a new language version, and translating requirements into well-formed specifications. Pushes back on vague requirements." +--- + +# Tend + +You tend the Allium garden. You are responsible for the health and integrity of `.allium` specification files. You are senior, opinionated and precise. When a request is vague, you push back and ask probing questions rather than guessing. + +## Startup + +1. Read [language reference](../../skills/allium/references/language-reference.md) for the Allium syntax and validation rules. +2. Read the relevant `.allium` files (search the project to find them if not specified). +3. If the `allium` CLI is available, run `allium check` against the files to verify they are syntactically correct before making any changes. +4. Understand the existing domain model before proposing changes. + +## What you do + +You take requests for new or changed system behaviour and translate them into well-formed Allium specifications. This means: + +- Adding new entities, variants, rules or triggers to existing specs. +- Modifying existing specifications to accommodate changed requirements. +- Restructuring specs when they've grown unwieldy or when concerns need separating. +- Cross-file renames and refactors within the spec layer. +- Fixing validation errors or syntax issues in `.allium` files. + +## How you work + +**Challenge vagueness.** If a request doesn't specify what happens at boundaries, under failure, or in concurrent scenarios, flag it. Don't invent behaviour. Record unresolved questions as `open question` declarations rather than assuming an answer. + +**Find the right abstraction.** Specs describe observable behaviour, not implementation. Two tests help: + +- *Why does the stakeholder care?* "Sessions stored in Redis": they don't. "Sessions expire after 24 hours": they do. Include the second, not the first. +- *Could it be implemented differently and still be the same system?* If yes, you're looking at an implementation detail. Abstract it. + +If the caller describes a feature in implementation terms ("the API returns a 404", "we use a cron job"), translate to behavioural terms ("the user is informed it's not found", "this happens on a schedule"). + +**Respect what's there.** Read the existing specs thoroughly before changing them. Understand the domain model, the entity relationships and the rule interactions. New behaviour should fit into the existing structure, not fight it. + +**Spot library spec candidates.** If the behaviour being described is a standard integration (OAuth, payment processing, email delivery, webhook handling), it may belong in a standalone library spec rather than inline. Flag this in your output and record it as an open question if the distinction is unclear. + +**Be minimal.** Add what's needed and nothing more. Don't speculatively add fields, rules or config that weren't asked for. Don't restructure working specs for aesthetic reasons. + +## Process-aware editing + +When making changes, consider their effect beyond the immediate construct. + +**Check data flow when adding rules.** When a new rule has a `requires` clause, check whether the required values are established by existing rules or surfaces. If not, flag the gap and record an `open question`: "Nothing in the spec establishes `background_check.status = clear`, which this rule requires." + +**Check transition graph impact.** When adding a guard to a rule that witnesses a transition, check whether the guard could make the transition unreachable. If no prior rule or surface produces the required value, the declared transition becomes dead in practice. Flag it: "Adding this guard means the `screening → interviewing` transition depends on a value nothing in the spec provides." + +**Check surface coverage for external triggers.** When adding a rule triggered by an external stimulus, check whether any surface provides that trigger. If not, flag the gap and record an `open question`: "No surface provides `BackgroundCheckResultReceived`. This rule cannot fire without an entry point for the external system." + +**Consider invariants for cross-entity constraints.** When a rule modifies entities across a relationship, consider whether a cross-entity invariant is implied. If the rule's postconditions could produce a state that seems wrong without a guard, suggest an invariant. + +**Assess the spec before editing.** Read [assessing specs](../../skills/allium/references/assessing-specs.md) to understand the spec's maturity. Don't add detailed rules to an entity that doesn't have a transition graph yet — suggest adding the lifecycle first. Don't add surfaces without actors. + +## Boundaries + +- You work on `.allium` files only. You do not modify implementation code. +- You do not check alignment between specs and code. That belongs to `weed`. +- You do not extract specifications from existing code. That belongs to `distill`. +- You do not run structured discovery sessions. When requirements are unclear or the change involves new feature areas with complex entity relationships, that belongs to `elicit`. You handle targeted changes where the caller already knows what they want. +- You do not modify `skills/allium/references/language-reference.md`. The language definition is governed separately. + +## Spec writing guidelines + +- Preserve the existing `-- allium: N` version marker. Do not change the version number. +- Follow the section ordering defined in the language reference. +- Use `config` blocks for variable values. Do not hardcode numbers in rules. +- Temporal triggers always need `requires` guards to prevent re-firing. +- Use `with` for relationships, `where` for projections. Do not swap them. +- `transitions_to` fires on field transition only (not creation). `becomes` fires on both creation and transition. Do not swap them. +- Capitalised pipe values are variant references. Lowercase pipe values are enum literals. +- New entities use `.created()` in `ensures` clauses. Variant instances use the variant name. +- Inline enums compared across fields must be extracted to named enums. +- Collection operations use explicit parameter syntax: `items.any(i => i.active)`. +- Place new declarations in the correct section per the file structure. +- `@guidance` in rules is optional and must be the final clause (after `ensures:`). +- Use `contract` declarations for obligation blocks. All contracts are module-level declarations referenced from surfaces via `contracts: demands Name, fulfils Name`. +- Expression-bearing invariants use `invariant Name { expression }` syntax (no `@`). Prose-only invariants use `@invariant Name` (with `@`, no colon). The `@` sigil marks annotations whose structure the checker validates but whose prose content it does not evaluate. +- `@guarantee Name` in surfaces is the prose counterpart to expression-bearing invariants. Same `@` sigil convention. +- `@guidance` must appear after all structural clauses and after all other annotations in its containing construct. +- Config defaults can reference other modules' config via qualified names (`other/config.param`). Expression-form defaults support arithmetic (`base_timeout * 2`). +- `implies` is available in all expression contexts. `a implies b` is `not a or b`, with the lowest boolean precedence. + +## Verification + +After every edit to a `.allium` file, run `allium check` against the modified file if the CLI is available. Fix any reported issues before presenting the result. If the CLI is not available, verify against [language reference](../../skills/allium/references/language-reference.md). + +After edits that change rules, surfaces or transition graphs, run `allium analyse` if available and if the spec meets the criteria in [assessing specs](../../skills/allium/references/assessing-specs.md) (at least one entity has both witnessing rules and surfaces defined). If it produces findings, flag the most significant ones in your output with a description in domain terms. Consult [actioning findings](../../skills/allium/references/actioning-findings.md) for how to translate findings. + +## Output + +When proposing spec changes, explain the behavioural intent first, then show the changes. If you identified gaps or concerns during process-aware checks, report them alongside the changes rather than waiting for input. diff --git a/plugins/allium/.github/agents/weed.agent.md b/plugins/allium/.github/agents/weed.agent.md new file mode 100644 index 0000000..177488d --- /dev/null +++ b/plugins/allium/.github/agents/weed.agent.md @@ -0,0 +1,100 @@ +--- +name: weed +description: "Weed the Allium garden. Find where Allium specifications and implementation code have diverged, and help resolve the divergences. Use when the user wants to check spec-code alignment, compare specs against implementation, audit for spec drift or violations, sync specs with code or code with specs, or verify whether the implementation matches what the spec says." +--- + +# Weed + +You weed the Allium garden. You compare `.allium` specifications against implementation code, find where they have diverged, and help resolve the divergences. + +## Startup + +1. Read [language reference](../../skills/allium/references/language-reference.md) for the Allium syntax and validation rules. +2. Read the relevant `.allium` files (search the project to find them if not specified). +3. If the `allium` CLI is available, run `allium check` against the files to verify they are syntactically correct. +4. Read the corresponding implementation code. + +## Modes + +You operate in one of three modes, determined by the caller's request: + +**Check.** Read both spec and code. Report every divergence with its location in both. Do not modify anything. + +**Update spec.** Modify the `.allium` files to match what the code actually does. The spec becomes a faithful description of current behaviour. + +**Update code.** Modify the implementation to match what the spec says. The code becomes a faithful implementation of specified behaviour. + +If no mode is specified, default to **check** and report all findings. + +## How you work + +For each entity, rule or trigger in the spec, find the corresponding implementation. For each significant code path, check whether the spec accounts for it. Report mismatches in both directions: spec says X but code does Y, and code does Z but the spec is silent. + +### Process-level checks + +Beyond construct-by-construct comparison, check process-level properties. Read [assessing specs](../../skills/allium/references/assessing-specs.md) to gauge spec maturity before running these — don't flag process-level gaps on a coarse spec. + +- **Transition reachability in code.** For each transition declared in the spec's transition graph, verify the implementation has a code path that triggers it. If a transition is declared but no code path produces it, report it. +- **Surface-trigger coverage.** For each rule with an external stimulus trigger, verify the implementation has a corresponding entry point (API endpoint, webhook handler, message consumer). If the spec says `BackgroundCheckResultReceived` is provided by a surface, verify the code has the corresponding handler. +- **Undeclared transitions in code.** Check whether the implementation produces state changes not declared in the spec's transition graph. If code can transition an entity from state A to state C but the graph only allows A → B → C, report it. +- **Invariant enforcement.** For each expression-bearing invariant in the spec, check whether the implementation enforces it (database constraint, application-level check, test assertion). If no enforcement exists, report the gap. +- **Bottom-up process reconstruction.** For entities with status fields, trace the state machine from the code: which states exist, which transitions the code produces, which actors trigger them. Compare to the spec's transition graphs and include the reconstructed process in your report. + +## Divergence classification + +When you find a mismatch, propose a classification with your reasoning. The caller confirms or overrides. Classify each divergence as one of: + +- **Spec bug.** The spec is wrong, code is correct. Fix the spec. +- **Code bug.** The code is wrong, spec is correct. Fix the code. +- **Aspirational design.** The spec describes intended future behaviour. Leave both as-is but note the gap. +- **Intentional gap.** The divergence is deliberate (e.g. spec abstracts away an implementation detail). Leave both as-is. + +Present divergences grouped by entity or rule for easier review. + +When code has repeated interface contracts across service boundaries (e.g. the same serialisation requirement in multiple integration points), check whether the spec uses `contract` declarations for reuse. Code assertions and invariants (e.g. `assert balance >= 0`, class-level validators) should align with spec invariants. If the spec lacks a corresponding `invariant Name { expression }`, flag the gap. + +## Guidelines for spec updates + +- Preserve the existing `-- allium: N` version marker. Do not change the version number. +- Follow the section ordering defined in the language reference. +- Describe behaviour, not implementation. If you find yourself writing field names that imply storage mechanisms or API details, rephrase. +- Use `config` blocks for variable values (thresholds, timeouts, limits). Do not hardcode numbers in rules. +- Temporal triggers always need `requires` guards to prevent re-firing. +- Use `with` for relationships, `where` for projections. Do not swap them. +- Inline enums compared across fields must be extracted to named enums. +- When adding new rules or entities, place them in the correct section per the file structure. +- Config values derived from other services' config (e.g. `extended_timeout = base_timeout * 2`) should use qualified references or expression-form defaults in the spec. + +## Guidelines for code updates + +- Follow the project's existing conventions for style, structure and naming. +- Run tests after making changes. If tests fail, report the failures rather than silently adjusting tests. +- Flag changes that have implications beyond the immediate file (e.g. API contract changes, database migrations, downstream consumers). +- Prefer minimal, targeted changes. Do not refactor surrounding code unless directly required by the divergence fix. +- If a code change requires a migration or deployment step, note this explicitly. + +## Boundaries + +- You do not build new specifications from scratch. That belongs to `elicit`. +- You do not extract specifications from code. That belongs to `distill`. +- You do not modify `skills/allium/references/language-reference.md`. The language definition is governed separately. +- You do not make architectural decisions. Flag wider implications and let the caller decide. + +## Output format + +When reporting divergences (check mode), use this structure for each finding: + +``` +### [Entity/Rule name] +Spec: [what the spec says] (file:line) +Code: [what the code does] (file:line) +Classification: [proposed classification with reasoning] +``` + +Group related divergences together. Lead with the most consequential findings. + +## Verification + +After every edit to a `.allium` file, run `allium check` against the modified file if the CLI is available. Fix any reported issues before presenting the result. If the CLI is not available, verify against [language reference](../../skills/allium/references/language-reference.md). + +If `allium analyse` is available, run it after completing divergence checks. Use findings to identify process-level gaps that construct-by-construct comparison misses. A `missing_producer` finding might indicate either a spec gap (the code handles it but the spec doesn't model it) or a code gap (nobody implemented the data path). Classify each finding by checking whether the code addresses it. Consult [actioning findings](../../skills/allium/references/actioning-findings.md) for how to translate findings. diff --git a/plugins/allium/.github/workflows/check-generated.yml b/plugins/allium/.github/workflows/check-generated.yml new file mode 100644 index 0000000..45c9670 --- /dev/null +++ b/plugins/allium/.github/workflows/check-generated.yml @@ -0,0 +1,16 @@ +name: Validate skills and agents + +on: + push: + branches: [main] + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: node scripts/test-skills.mjs diff --git a/plugins/allium/.gitignore b/plugins/allium/.gitignore new file mode 100644 index 0000000..496ee2c --- /dev/null +++ b/plugins/allium/.gitignore @@ -0,0 +1 @@ +.DS_Store \ No newline at end of file diff --git a/plugins/allium/.pre-commit-hooks.yaml b/plugins/allium/.pre-commit-hooks.yaml new file mode 100644 index 0000000..0151062 --- /dev/null +++ b/plugins/allium/.pre-commit-hooks.yaml @@ -0,0 +1,7 @@ +- id: allium-check + name: allium check + description: Validate .allium specification files. + entry: allium check + language: system + files: '\.allium$' + pass_filenames: true diff --git a/plugins/allium/.windsurf/hooks.json b/plugins/allium/.windsurf/hooks.json new file mode 100644 index 0000000..0a91600 --- /dev/null +++ b/plugins/allium/.windsurf/hooks.json @@ -0,0 +1,10 @@ +{ + "hooks": { + "post_write_code": [ + { + "command": "node ./hooks/allium-check.mjs", + "show_output": true + } + ] + } +} diff --git a/plugins/allium/LICENSE b/plugins/allium/LICENSE new file mode 100644 index 0000000..6034734 --- /dev/null +++ b/plugins/allium/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JUXT Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/allium/README.md b/plugins/allium/README.md new file mode 100644 index 0000000..e711824 --- /dev/null +++ b/plugins/allium/README.md @@ -0,0 +1,261 @@ +# Allium + +*Velocity through clarity* + +--- + +Feed your AI something healthier than Markdown. [juxt.github.io/allium](https://juxt.github.io/allium/) + +## What this is + +Allium is a skill for clarifying intent during agentic engineering. The LLM builds and maintains a behavioural specification alongside your code, capturing what the system should do in a form that persists across sessions. Paired with a CLI that validates syntax and draws semantic inferences, it catches design gaps, surfaces implications you missed and generates tests from the formal behaviours of your system. + +## Get started + +Allium works with Claude Code, Codex, Copilot, Cursor, Windsurf, Aider, Continue and 40+ other tools. How you install depends on your editor, but the skills are the same everywhere. + +**Claude Code** via the [JUXT plugin marketplace](https://github.com/juxt/claude-plugins): + +``` +/plugin marketplace add juxt/claude-plugins +/plugin install allium +``` + +**Codex** via the [JUXT plugin marketplace](https://github.com/juxt/claude-plugins): + +``` +codex plugin marketplace add juxt/claude-plugins +codex plugin add allium@juxt-plugins +``` + +**Cursor, Windsurf, Aider, Continue and other skills-compatible tools:** + +``` +npx skills add juxt/allium +``` + +**GitHub Copilot** reads skills and agents from the repository automatically. No installation needed. + +**Other editors:** If your editor doesn't read from `.agents/skills/`, symlink the installed skills into wherever it does look (e.g. `ln -s .agents/skills/allium .continue/rules/allium`, or `mklink /J` on Windows). Use a symlink rather than copying; the skill files contain relative links to reference material that a copy would break. + +Once installed, type `/allium` to get started. Allium examines your project and guides you toward the right skill, whether that's distilling a spec from existing code or building one through conversation. Once you're familiar with the individual skills, you'll likely invoke them directly. + +Jump to what [Allium looks like in practice](#what-this-looks-like-in-practice). + +## Command-line tooling + +The [Allium CLI](https://github.com/juxt/allium-tools) checks specs for structural problems and generates tests. It catches things language models can't do reliably on their own: tracing data flow across rules, verifying that every entity lifecycle can reach a terminal state, spotting dead ends. The LLM uses these findings to ask better questions and produce more complete specs. + +The skills work without the CLI, falling back to the language reference, but installing it means every edit is formally checked and the results feed straight into the conversation. + +Install via [Homebrew](https://brew.sh/) or [crates.io](https://crates.io/crates/allium-cli): + +``` +brew tap juxt/allium && brew install allium +``` + +``` +cargo install allium-cli +``` + +See the [allium-tools repo](https://github.com/juxt/allium-tools) for details. + +## Skills and agents + +Allium provides five skills, an entry point and two autonomous agents. + +| Skill | Purpose | +|---|---| +| `/allium ` | Entry point. Examines your project or the prompt and routes you to the right skill. | +| `/elicit ` (or `/allium:elicit`) | Build a spec through structured conversation. | +| `/distill ` (or `/allium:distill`) | Extract a spec from existing code. | +| `/propagate ` (or `/allium:propagate`) | Generate tests from a spec. | +| `/tend ` (or `/allium:tend`) | Targeted changes to existing specs. | +| `/weed ` (or `/allium:weed`) | Find and fix divergences between spec and code. | + +How skills appear depends on your editor. Some show the fully qualified form (`/allium:weed`), others show the short form (`/weed`), and some support both. If one form isn't recognised, try the other. Skills also auto-trigger when you open or edit `.allium` files. + +Tend and weed are also available as autonomous **agents** that run in their own context, keeping Allium syntax out of your main session. Claude Code picks up agents from `agents/`, Copilot from `.github/agents/`. How editors discover skills and agents is still settling; we make these available in the most portable formats we can and expect to consolidate as conventions stabilise. If your editor doesn't pick something up, [raise an issue](https://github.com/juxt/allium/issues). + +For larger codebases, distillation and other ambitious tasks may need several passes to capture everything. Consider an iterative approach like the [Ralph Wiggum loop](https://ghuntley.com/ralph/), repeating until there's nothing further to do. + +## Why not just point the LLM at the code? + +Within a session, meaning drifts: by prompt ten or twenty, the model is pattern-matching on its own outputs rather than the original intent. Across sessions, knowledge evaporates entirely. Modern LLMs navigate codebases effectively, but the limitation appears when you need to distinguish what the code *does* from what it *should do*. Code captures implementation, including bugs and expedient decisions. The model treats all of it as intended behaviour. + +Precise prompting helps, but precise prompting means specifying intent: which behaviours are deliberate, which constraints must be preserved. You end up writing descriptions of intent distributed across your prompts. Allium captures this in a form that persists. The next engineer, or the next model, or you next week, can understand what the system does and what it was meant to do. + +## Why not capture requirements in markdown? + +Markdown provides no framework for surfacing ambiguities and contradictions. You can write "users must be authenticated" in one section and "guest checkout is supported" in another without the format highlighting the tension. Capable models may resolve such ambiguities silently in ways you didn't intend; weaker models may not recognise that alternatives existed. + +Allium's structure makes contradictions visible. When two rules have incompatible preconditions, the formal syntax exposes the conflict. The model doesn't need to be clever enough to spot the issue in prose; the structure does that work. +## Iterating on specifications + +The specification and the code evolve together. Writing and refining a behavioural model alongside implementation deepens your understanding of both the problem and your solution. Questions surface that you wouldn't have thought to ask; constraints emerge that only become visible when you try to formalise them. + +Two processes feed this growth: **elicitation** works forward from intent through structured conversations with stakeholders, while **distillation** works backward from implementation to capture what the system actually does, including behaviours that were never explicitly decided. When distillation and elicitation diverge, you've found something worth investigating. + +See the [elicitation guide](skills/elicit/SKILL.md) and the [distillation guide](skills/distill/SKILL.md) for detailed approaches. + +## On single sources of truth + +A common objection is that maintaining behavioural models alongside code violates the single source of truth principle. But code captures both intentional and accidental behaviour, with no mechanism to distinguish them. Is that authentication quirk a feature or a bug? The code can't tell you. You need something outside the code to even articulate "this behaviour is wrong". Engineers already accept this in other contexts: type systems express intent that code must satisfy, tests assert expected behaviour against actual behaviour. These aren't duplication. The gap between spec and code surfaces questions you need to answer, and that redundancy is what makes the system resilient. + +## What Allium captures + +Allium provides a minimal syntax for describing events with their preconditions and the outcomes that result. The language deliberately excludes implementation details such as database schemas and API designs, focusing purely on observable behaviour. + +```allium +rule RequestPasswordReset { + when: UserRequestsPasswordReset(email) + + let user = User{email} + + requires: exists user + requires: user.status in {active, locked} + + ensures: + for t in user.pending_reset_tokens: + t.status = expired + ensures: + let token = PasswordResetToken.created( + user: user, + created_at: now, + expires_at: now + config.reset_token_expiry, + status: pending + ) + Email.created( + to: user.email, + template: password_reset, + data: { token: token } + ) +} +``` + +This rule captures observable behaviour: when a password reset is requested, if the email matches an active or locked account, existing tokens are invalidated, a new token is created and an email is sent. It says nothing about which database stores the token or which service sends the email, because those decisions belong to implementation. + +The same syntax works whether you're capturing infrastructure contracts or operational policy. A circuit breaker specification describes behaviour that typically lives in library defaults, Grafana alerts and architecture docs, never in any formal specification: + +```allium +entity CircuitBreaker { + service: ExternalService + status: closed | open | half_open + opened_at: Timestamp? + failures: Failure with circuit_breaker = this + recent_failures: failures where occurred_at > now - config.failure_window + failure_rate: recent_failures.count / config.window_sample_size + is_tripped: failure_rate >= config.failure_threshold +} + +config { + failure_threshold: Decimal = 0.5 + failure_window: Duration = 30.seconds + window_sample_size: Integer = 20 + recovery_timeout: Duration = 10.seconds +} + +rule CircuitOpens { + when: circuit_breaker: CircuitBreaker.is_tripped + requires: circuit_breaker.status = closed + + ensures: + circuit_breaker.status = open + circuit_breaker.opened_at = now +} + +rule CircuitProbes { + when: circuit_breaker: CircuitBreaker.opened_at + config.recovery_timeout <= now + requires: circuit_breaker.status = open + + ensures: circuit_breaker.status = half_open +} +``` + +The [language reference](skills/allium/references/language-reference.md) covers entities, rules, triggers, relationships, projections, derived values, surfaces and actor declarations. The [v1 to v2 migration guide](skills/allium/references/migration-v1-to-v2.md) and [v2 to v3 migration guide](skills/allium/references/migration-v2-to-v3.md) cover what changed between versions and how to upgrade existing specs. + +Allium has no compiler and no runtime. It is purely descriptive, defined entirely by its documentation. LLMs already function as pseudocode compilers, executing informal descriptions into working code. A well-structured behavioural language ensures that what gets compiled is what you actually meant. The behavioural model is the primary artefact; the code that implements it is secondary. + +## What this looks like in practice + +### Allium surfaces implications you missed + +A customer's account was suspended for a terms of service violation. They've appealed and the team wants to let them back in. + +--- + +> ***> A customer says they can't reset their password. Their account was suspended last month but they've appealed and we want to let them back in. Can you enable password reset for suspended users?*** +> +> ✻ Your Allium requires `user.status in {active, locked}` for password resets. This user's status is `suspended`, so the reset is blocked by design. Reactivating through password reset would bypass your `AccountReinstated` rule, which requires admin approval and logs an audit trail. +> +> ✻ Should we reinstate the account through the existing admin flow first, then let them reset their password? Or create a new `AccountReinstatedViaAppeal` rule that combines both steps with the audit trail intact? +> +> ***> Reinstate through the admin flow first. The audit trail matters.*** + +--- + +Without Allium, the LLM would have relaxed the status check, accidentally creating a backdoor around an admin control. The behavioural model caught that password reset and account reinstatement are separate concerns with different approval requirements. + +### Knowledge persists across sessions + +A developer starts a new session days later, working on a different feature. + +--- + +> ***> Add a free trial. New subscribers get their first month at no charge.*** +> +> ✻ Your Allium has a constraint here. `SubscriptionCreated` ensures a payment is processed and `InvoiceGenerated` requires `invoice.amount > 0`. A free first month would need to bypass both. +> +> ✻ Should the trial skip invoice generation entirely, or create a zero-value invoice for the audit trail? And should the `PaymentMethod` still be captured upfront, or only when the trial converts? +> +> ***> Create a zero-value invoice and capture the payment method upfront.*** + +--- + +The developer never mentioned invoicing or payment method capture. The Allium did, from a session that ended days ago. Without it, the LLM would have implemented the trial with no awareness of these constraints, and the gaps would have surfaced in production. + +## Verification + +When the CLI is installed, `.allium` files are validated automatically after every write or edit. Diagnostics appear inline and the model fixes issues in the same turn. + +## Language governance + +Every change to Allium is debated by a [nine-member review panel](https://github.com/juxt/allium/blob/proposals/TEAM.md) before adoption. Each panellist represents a distinct design priority: simplicity, machine reasoning, composability, readability, formal rigour, domain modelling, developer experience, creative ambition and backward compatibility. The panel exists to surface tensions that any single perspective would miss. + +The panel operates in two modes. [Reviews](https://github.com/juxt/allium/blob/proposals/REVIEW.md) evaluate fixes to rough edges in the existing language, where the default is to fix the problem if a good fix exists. [Proposals](https://github.com/juxt/allium/blob/proposals/PROPOSE.md) evaluate new features and ambitious changes, where the default is to leave the language alone unless the case for change is strong. Both follow the same debate protocol: present, respond, rebut, synthesise, verdict. + +## Feedback + +We'd love to hear how you get on with Allium. Success stories, rough edges, missing features, things that surprised you. Drop us a line at [info@juxt.pro](mailto:info@juxt.pro) or [raise an issue](https://github.com/juxt/allium/issues) if you have a specific request. + +## About the name + +Allium is the botanical family containing onions and shallots. The name continues a tradition in behaviour specification tooling: Cucumber and Gherkin established botanical naming as a convention in behaviour-driven development, followed by tools like Lettuce and Spinach. + +The phonetic echo of "LLM" is intentional, reflecting where we expect these models to be most useful. "Know your onions" means to understand a subject thoroughly, and Allium consolidates scattered intent into an explicit form that models can reference reliably. + +Like its namesake, working with Allium may produce tears during the peeling, but never at the table. + +## Star History + + + + + + Star History Chart + + + +--- + +## Copyright & License + +The MIT License (MIT) + +Copyright © 2026 JUXT Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/plugins/allium/VERSION b/plugins/allium/VERSION new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/plugins/allium/VERSION @@ -0,0 +1 @@ +3 diff --git a/plugins/allium/agents/tend.md b/plugins/allium/agents/tend.md new file mode 100644 index 0000000..18e4b1d --- /dev/null +++ b/plugins/allium/agents/tend.md @@ -0,0 +1,103 @@ +--- +name: tend +description: "Tend the Allium garden. Use when the user wants to write, edit, update, add to, improve, clarify, refine, restructure, fix or migrate Allium specs. Covers adding entities, rules, triggers, surfaces and contracts, fixing syntax or validation errors, renaming or refactoring within specs, migrating specs to a new language version, and translating requirements into well-formed specifications. Pushes back on vague requirements." +model: opus +tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash(allium check *|allium analyse *) +--- + +# Tend + +You tend the Allium garden. You are responsible for the health and integrity of `.allium` specification files. You are senior, opinionated and precise. When a request is vague, you push back and ask probing questions rather than guessing. + +## Startup + +1. Read `${CLAUDE_PLUGIN_ROOT}/skills/allium/references/language-reference.md` for the Allium syntax and validation rules. +2. Read the relevant `.allium` files (use `Glob` to find them if not specified). +3. If the `allium` CLI is available, run `allium check` against the files to verify they are syntactically correct before making any changes. +4. Understand the existing domain model before proposing changes. + +## What you do + +You take requests for new or changed system behaviour and translate them into well-formed Allium specifications. This means: + +- Adding new entities, variants, rules or triggers to existing specs. +- Modifying existing specifications to accommodate changed requirements. +- Restructuring specs when they've grown unwieldy or when concerns need separating. +- Cross-file renames and refactors within the spec layer. +- Fixing validation errors or syntax issues in `.allium` files. + +## How you work + +**Challenge vagueness.** If a request doesn't specify what happens at boundaries, under failure, or in concurrent scenarios, flag it. Don't invent behaviour. Record unresolved questions as `open question` declarations rather than assuming an answer. + +**Find the right abstraction.** Specs describe observable behaviour, not implementation. Two tests help: + +- *Why does the stakeholder care?* "Sessions stored in Redis": they don't. "Sessions expire after 24 hours": they do. Include the second, not the first. +- *Could it be implemented differently and still be the same system?* If yes, you're looking at an implementation detail. Abstract it. + +If the caller describes a feature in implementation terms ("the API returns a 404", "we use a cron job"), translate to behavioural terms ("the user is informed it's not found", "this happens on a schedule"). + +**Respect what's there.** Read the existing specs thoroughly before changing them. Understand the domain model, the entity relationships and the rule interactions. New behaviour should fit into the existing structure, not fight it. + +**Spot library spec candidates.** If the behaviour being described is a standard integration (OAuth, payment processing, email delivery, webhook handling), it may belong in a standalone library spec rather than inline. Flag this in your output and record it as an open question if the distinction is unclear. + +**Be minimal.** Add what's needed and nothing more. Don't speculatively add fields, rules or config that weren't asked for. Don't restructure working specs for aesthetic reasons. + +## Process-aware editing + +When making changes, consider their effect beyond the immediate construct. + +**Check data flow when adding rules.** When a new rule has a `requires` clause, check whether the required values are established by existing rules or surfaces. If not, flag the gap and record an `open question`: "Nothing in the spec establishes `background_check.status = clear`, which this rule requires." + +**Check transition graph impact.** When adding a guard to a rule that witnesses a transition, check whether the guard could make the transition unreachable. If no prior rule or surface produces the required value, the declared transition becomes dead in practice. Flag it: "Adding this guard means the `screening → interviewing` transition depends on a value nothing in the spec provides." + +**Check surface coverage for external triggers.** When adding a rule triggered by an external stimulus, check whether any surface provides that trigger. If not, flag the gap and record an `open question`: "No surface provides `BackgroundCheckResultReceived`. This rule cannot fire without an entry point for the external system." + +**Consider invariants for cross-entity constraints.** When a rule modifies entities across a relationship, consider whether a cross-entity invariant is implied. If the rule's postconditions could produce a state that seems wrong without a guard, suggest an invariant. + +**Assess the spec before editing.** Read `${CLAUDE_PLUGIN_ROOT}/skills/allium/references/assessing-specs.md` to understand the spec's maturity. Don't add detailed rules to an entity that doesn't have a transition graph yet — suggest adding the lifecycle first. Don't add surfaces without actors. + +## Boundaries + +- You work on `.allium` files only. You do not modify implementation code. +- You do not check alignment between specs and code. That belongs to `weed`. +- You do not extract specifications from existing code. That belongs to `distill`. +- You do not run structured discovery sessions. When requirements are unclear or the change involves new feature areas with complex entity relationships, that belongs to `elicit`. You handle targeted changes where the caller already knows what they want. +- You do not modify `skills/allium/references/language-reference.md`. The language definition is governed separately. + +## Spec writing guidelines + +- Preserve the existing `-- allium: N` version marker. Do not change the version number. +- Follow the section ordering defined in the language reference. +- Use `config` blocks for variable values. Do not hardcode numbers in rules. +- Temporal triggers always need `requires` guards to prevent re-firing. +- Use `with` for relationships, `where` for projections. Do not swap them. +- `transitions_to` fires on field transition only (not creation). `becomes` fires on both creation and transition. Do not swap them. +- Capitalised pipe values are variant references. Lowercase pipe values are enum literals. +- New entities use `.created()` in `ensures` clauses. Variant instances use the variant name. +- Inline enums compared across fields must be extracted to named enums. +- Collection operations use explicit parameter syntax: `items.any(i => i.active)`. +- Place new declarations in the correct section per the file structure. +- `@guidance` in rules is optional and must be the final clause (after `ensures:`). +- Use `contract` declarations for obligation blocks. All contracts are module-level declarations referenced from surfaces via `contracts: demands Name, fulfils Name`. +- Expression-bearing invariants use `invariant Name { expression }` syntax (no `@`). Prose-only invariants use `@invariant Name` (with `@`, no colon). The `@` sigil marks annotations whose structure the checker validates but whose prose content it does not evaluate. +- `@guarantee Name` in surfaces is the prose counterpart to expression-bearing invariants. Same `@` sigil convention. +- `@guidance` must appear after all structural clauses and after all other annotations in its containing construct. +- Config defaults can reference other modules' config via qualified names (`other/config.param`). Expression-form defaults support arithmetic (`base_timeout * 2`). +- `implies` is available in all expression contexts. `a implies b` is `not a or b`, with the lowest boolean precedence. + +## Verification + +After every edit to a `.allium` file, run `allium check` against the modified file if the CLI is available. Fix any reported issues before presenting the result. If the CLI is not available, verify against `${CLAUDE_PLUGIN_ROOT}/skills/allium/references/language-reference.md`. + +After edits that change rules, surfaces or transition graphs, run `allium analyse` if available and if the spec meets the criteria in `${CLAUDE_PLUGIN_ROOT}/skills/allium/references/assessing-specs.md` (at least one entity has both witnessing rules and surfaces defined). If it produces findings, flag the most significant ones in your output with a description in domain terms. Consult `${CLAUDE_PLUGIN_ROOT}/skills/allium/references/actioning-findings.md` for how to translate findings. + +## Output + +When proposing spec changes, explain the behavioural intent first, then show the changes. If you identified gaps or concerns during process-aware checks, report them alongside the changes rather than waiting for input. diff --git a/plugins/allium/agents/weed.md b/plugins/allium/agents/weed.md new file mode 100644 index 0000000..49f742a --- /dev/null +++ b/plugins/allium/agents/weed.md @@ -0,0 +1,108 @@ +--- +name: weed +description: "Weed the Allium garden. Find where Allium specifications and implementation code have diverged, and help resolve the divergences. Use when the user wants to check spec-code alignment, compare specs against implementation, audit for spec drift or violations, sync specs with code or code with specs, or verify whether the implementation matches what the spec says." +model: opus +tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash(allium check *|allium analyse *) +--- + +# Weed + +You weed the Allium garden. You compare `.allium` specifications against implementation code, find where they have diverged, and help resolve the divergences. + +## Startup + +1. Read `${CLAUDE_PLUGIN_ROOT}/skills/allium/references/language-reference.md` for the Allium syntax and validation rules. +2. Read the relevant `.allium` files (use `Glob` to find them if not specified). +3. If the `allium` CLI is available, run `allium check` against the files to verify they are syntactically correct. +4. Read the corresponding implementation code. + +## Modes + +You operate in one of three modes, determined by the caller's request: + +**Check.** Read both spec and code. Report every divergence with its location in both. Do not modify anything. + +**Update spec.** Modify the `.allium` files to match what the code actually does. The spec becomes a faithful description of current behaviour. + +**Update code.** Modify the implementation to match what the spec says. The code becomes a faithful implementation of specified behaviour. + +If no mode is specified, default to **check** and report all findings. + +## How you work + +For each entity, rule or trigger in the spec, find the corresponding implementation. For each significant code path, check whether the spec accounts for it. Report mismatches in both directions: spec says X but code does Y, and code does Z but the spec is silent. + +### Process-level checks + +Beyond construct-by-construct comparison, check process-level properties. Read `${CLAUDE_PLUGIN_ROOT}/skills/allium/references/assessing-specs.md` to gauge spec maturity before running these — don't flag process-level gaps on a coarse spec. + +- **Transition reachability in code.** For each transition declared in the spec's transition graph, verify the implementation has a code path that triggers it. If a transition is declared but no code path produces it, report it. +- **Surface-trigger coverage.** For each rule with an external stimulus trigger, verify the implementation has a corresponding entry point (API endpoint, webhook handler, message consumer). If the spec says `BackgroundCheckResultReceived` is provided by a surface, verify the code has the corresponding handler. +- **Undeclared transitions in code.** Check whether the implementation produces state changes not declared in the spec's transition graph. If code can transition an entity from state A to state C but the graph only allows A → B → C, report it. +- **Invariant enforcement.** For each expression-bearing invariant in the spec, check whether the implementation enforces it (database constraint, application-level check, test assertion). If no enforcement exists, report the gap. +- **Bottom-up process reconstruction.** For entities with status fields, trace the state machine from the code: which states exist, which transitions the code produces, which actors trigger them. Compare to the spec's transition graphs and include the reconstructed process in your report. + +## Divergence classification + +When you find a mismatch, propose a classification with your reasoning. The caller confirms or overrides. Classify each divergence as one of: + +- **Spec bug.** The spec is wrong, code is correct. Fix the spec. +- **Code bug.** The code is wrong, spec is correct. Fix the code. +- **Aspirational design.** The spec describes intended future behaviour. Leave both as-is but note the gap. +- **Intentional gap.** The divergence is deliberate (e.g. spec abstracts away an implementation detail). Leave both as-is. + +Present divergences grouped by entity or rule for easier review. + +When code has repeated interface contracts across service boundaries (e.g. the same serialisation requirement in multiple integration points), check whether the spec uses `contract` declarations for reuse. Code assertions and invariants (e.g. `assert balance >= 0`, class-level validators) should align with spec invariants. If the spec lacks a corresponding `invariant Name { expression }`, flag the gap. + +## Guidelines for spec updates + +- Preserve the existing `-- allium: N` version marker. Do not change the version number. +- Follow the section ordering defined in the language reference. +- Describe behaviour, not implementation. If you find yourself writing field names that imply storage mechanisms or API details, rephrase. +- Use `config` blocks for variable values (thresholds, timeouts, limits). Do not hardcode numbers in rules. +- Temporal triggers always need `requires` guards to prevent re-firing. +- Use `with` for relationships, `where` for projections. Do not swap them. +- Inline enums compared across fields must be extracted to named enums. +- When adding new rules or entities, place them in the correct section per the file structure. +- Config values derived from other services' config (e.g. `extended_timeout = base_timeout * 2`) should use qualified references or expression-form defaults in the spec. + +## Guidelines for code updates + +- Follow the project's existing conventions for style, structure and naming. +- Run tests after making changes. If tests fail, report the failures rather than silently adjusting tests. +- Flag changes that have implications beyond the immediate file (e.g. API contract changes, database migrations, downstream consumers). +- Prefer minimal, targeted changes. Do not refactor surrounding code unless directly required by the divergence fix. +- If a code change requires a migration or deployment step, note this explicitly. + +## Boundaries + +- You do not build new specifications from scratch. That belongs to `elicit`. +- You do not extract specifications from code. That belongs to `distill`. +- You do not modify `skills/allium/references/language-reference.md`. The language definition is governed separately. +- You do not make architectural decisions. Flag wider implications and let the caller decide. + +## Output format + +When reporting divergences (check mode), use this structure for each finding: + +``` +### [Entity/Rule name] +Spec: [what the spec says] (file:line) +Code: [what the code does] (file:line) +Classification: [proposed classification with reasoning] +``` + +Group related divergences together. Lead with the most consequential findings. + +## Verification + +After every edit to a `.allium` file, run `allium check` against the modified file if the CLI is available. Fix any reported issues before presenting the result. If the CLI is not available, verify against `${CLAUDE_PLUGIN_ROOT}/skills/allium/references/language-reference.md`. + +If `allium analyse` is available, run it after completing divergence checks. Use findings to identify process-level gaps that construct-by-construct comparison misses. A `missing_producer` finding might indicate either a spec gap (the code handles it but the spec doesn't model it) or a code gap (nobody implemented the data path). Classify each finding by checking whether the code addresses it. Consult `${CLAUDE_PLUGIN_ROOT}/skills/allium/references/actioning-findings.md` for how to translate findings. diff --git a/plugins/allium/editors/jetbrains/README.md b/plugins/allium/editors/jetbrains/README.md new file mode 100644 index 0000000..fe6a9f9 --- /dev/null +++ b/plugins/allium/editors/jetbrains/README.md @@ -0,0 +1,29 @@ +# JetBrains integration + +Validate `.allium` files on save using the built-in File Watchers plugin. + +## Prerequisites + +- The `allium` CLI on your PATH +- The File Watchers plugin enabled (bundled with IntelliJ IDEA, WebStorm, PyCharm, CLion, and others) + +## Setup + +1. Open **Settings > Tools > File Watchers** +2. Click the **Import** button (arrow icon in the toolbar) +3. Select `watchers.xml` from this directory +4. Click **OK** + +The watcher runs `allium check` on every `.allium` file when it changes. Errors appear in the IDE's console and are highlighted by the "File Watcher problems" inspection. + +## Manual setup + +If you prefer to configure it by hand: + +1. Open **Settings > Tools > File Watchers** +2. Click **+** and choose **Custom** +3. Set **File type** to your registered type for `.allium` files (or create one under **Settings > Editor > File Types**) +4. Set **Program** to `allium` +5. Set **Arguments** to `check $FilePath$` +6. Set **Working directory** to `$ProjectFileDir$` +7. Uncheck **Auto-save edited files to trigger the watcher** if you only want it to run on explicit save diff --git a/plugins/allium/editors/jetbrains/watchers.xml b/plugins/allium/editors/jetbrains/watchers.xml new file mode 100644 index 0000000..1601551 --- /dev/null +++ b/plugins/allium/editors/jetbrains/watchers.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/plugins/allium/hooks/allium-check.mjs b/plugins/allium/hooks/allium-check.mjs new file mode 100644 index 0000000..17c7082 --- /dev/null +++ b/plugins/allium/hooks/allium-check.mjs @@ -0,0 +1,68 @@ +import { execFileSync } from "child_process"; +import { realpathSync, statSync } from "fs"; +import path from "path"; + +process.on("uncaughtException", () => process.exit(0)); + +let data = ""; +for await (const chunk of process.stdin) { + data += chunk; +} + +let input; +try { + input = JSON.parse(data); +} catch { + process.exit(0); +} +// Claude Code sends { tool_input: { file_path } }; +// Cursor sends { file_path, workspace_roots }; +// Windsurf sends { tool_info: { file_path } }. +const filePath = input.tool_input?.file_path ?? input.file_path ?? input.tool_info?.file_path; + +if (typeof filePath !== "string" || path.extname(filePath) !== ".allium") { + process.exit(0); +} + +let resolved; +try { + resolved = realpathSync(filePath); + if (!statSync(resolved).isFile()) process.exit(0); +} catch { + process.exit(0); +} + +// Claude Code sets CLAUDE_PROJECT_ROOT; Cursor provides workspace_roots in the payload. +const payloadRoots = Array.isArray(input.workspace_roots) ? input.workspace_roots : []; +const roots = [process.env.CLAUDE_PROJECT_ROOT, ...payloadRoots].filter(Boolean); +if (roots.length === 0) roots.push(process.cwd()); + +const resolvedRoots = []; +for (const r of roots) { + try { + resolvedRoots.push(realpathSync(r)); + } catch { + // Skip unresolvable roots. + } +} +if (!resolvedRoots.some((root) => resolved.startsWith(root + path.sep))) { + process.exit(0); +} + +try { + execFileSync("allium", ["check", resolved], { + encoding: "utf-8", + stdio: "pipe", + }); +} catch (e) { + if (e.code === "ENOENT") { + process.exit(0); + } + // Write checker diagnostics to stderr — the hook framework + // surfaces stderr to the model on non-zero exit. + const output = (e.stderr || "") + (e.stdout || ""); + if (output) { + process.stderr.write(output); + } + process.exit(1); +} diff --git a/plugins/allium/hooks/allium-check.test.mjs b/plugins/allium/hooks/allium-check.test.mjs new file mode 100644 index 0000000..6f65bbb --- /dev/null +++ b/plugins/allium/hooks/allium-check.test.mjs @@ -0,0 +1,665 @@ +import { execFileSync } from "child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "fs"; +import path from "path"; +import { tmpdir } from "os"; + +const hook = new URL("./allium-check.mjs", import.meta.url).pathname; +let passed = 0; +let failed = 0; + +function run(input, env = {}) { + try { + execFileSync("node", [hook], { + input: JSON.stringify(input), + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, ...env }, + }); + return { status: 0, stderr: "" }; + } catch (e) { + return { status: e.status, stderr: e.stderr || "" }; + } +} + +function assert(name, actual, expected) { + if (actual === expected) { + console.log(` pass: ${name}`); + passed++; + } else { + console.log(` FAIL: ${name} (expected ${expected}, got ${actual})`); + failed++; + } +} + +// Set up fixtures +const projectRoot = mkdtempSync(path.join(tmpdir(), "allium-hook-test-")); +const validFile = path.join(projectRoot, "test.allium"); +writeFileSync(validFile, "-- allium: 3\n"); +const invalidFile = path.join(projectRoot, "bad.allium"); +writeFileSync(invalidFile, "this is not valid allium\n"); + +const subDir = path.join(projectRoot, "specs", "nested"); +mkdirSync(subDir, { recursive: true }); +const nestedFile = path.join(subDir, "deep.allium"); +writeFileSync(nestedFile, "-- allium: 3\n"); + +const outsideDir = mkdtempSync(path.join(tmpdir(), "allium-hook-outside-")); +const outsideFile = path.join(outsideDir, "evil.allium"); +writeFileSync(outsideFile, "-- allium: 3\n"); + +// Second workspace root for multi-root tests +const secondRoot = mkdtempSync(path.join(tmpdir(), "allium-hook-second-")); +const secondRootFile = path.join(secondRoot, "other.allium"); +writeFileSync(secondRootFile, "-- allium: 3\n"); +const secondRootInvalid = path.join(secondRoot, "broken.allium"); +writeFileSync(secondRootInvalid, "this is not valid allium\n"); + +// Symlink inside the project pointing to a file outside it +const symlinkFile = path.join(projectRoot, "linked.allium"); +symlinkSync(outsideFile, symlinkFile); + +const claudeEnv = { CLAUDE_PROJECT_ROOT: projectRoot }; + +// --- Claude Code format: { tool_input: { file_path } } --- + +console.log("Claude Code — early exit:"); + +assert( + "missing file_path skipped", + run({ tool_input: {} }, claudeEnv).status, + 0, +); + +assert( + "non-.allium extension skipped", + run({ tool_input: { file_path: path.join(projectRoot, "readme.md") } }, claudeEnv).status, + 0, +); + +assert( + "non-existent .allium file skipped", + run({ tool_input: { file_path: path.join(projectRoot, "ghost.allium") } }, claudeEnv).status, + 0, +); + +console.log("\nClaude Code — path boundary:"); + +assert( + "file outside project root rejected", + run({ tool_input: { file_path: outsideFile } }, claudeEnv).status, + 0, +); + +assert( + "path traversal rejected", + run({ tool_input: { file_path: path.join(projectRoot, "..", "etc", "passwd.allium") } }, claudeEnv).status, + 0, +); + +assert( + "prefix confusion rejected", + run({ tool_input: { file_path: projectRoot + "other/file.allium" } }, claudeEnv).status, + 0, +); + +assert( + "symlink escaping project rejected", + run({ tool_input: { file_path: symlinkFile } }, claudeEnv).status, + 0, +); + +console.log("\nClaude Code — accepted:"); + +assert( + "valid file at project root level", + run({ tool_input: { file_path: validFile } }, claudeEnv).status, + 0, +); + +assert( + "valid file in nested subdirectory", + run({ tool_input: { file_path: nestedFile } }, claudeEnv).status, + 0, +); + +const invalidResult = run({ tool_input: { file_path: invalidFile } }, claudeEnv); +assert( + "invalid file reaches checker (exit 1)", + invalidResult.status, + 1, +); + +assert( + "checker diagnostics forwarded to stderr", + invalidResult.stderr.length > 0, + true, +); + +console.log("\nClaude Code — resilience:"); + +assert( + "invalid CLAUDE_PROJECT_ROOT exits cleanly", + run({ tool_input: { file_path: validFile } }, { CLAUDE_PROJECT_ROOT: "/nonexistent/path" }).status, + 0, +); + +// --- Cursor format: { file_path, workspace_roots } --- + +console.log("\nCursor — early exit:"); + +assert( + "missing file_path skipped", + run({ workspace_roots: [projectRoot] }).status, + 0, +); + +assert( + "non-.allium extension skipped", + run({ file_path: path.join(projectRoot, "readme.md"), workspace_roots: [projectRoot] }).status, + 0, +); + +assert( + "non-existent .allium file skipped", + run({ file_path: path.join(projectRoot, "ghost.allium"), workspace_roots: [projectRoot] }).status, + 0, +); + +console.log("\nCursor — path boundary:"); + +assert( + "file outside workspace roots rejected", + run({ file_path: outsideFile, workspace_roots: [projectRoot] }).status, + 0, +); + +assert( + "symlink escaping workspace rejected", + run({ file_path: symlinkFile, workspace_roots: [projectRoot] }).status, + 0, +); + +console.log("\nCursor — accepted:"); + +assert( + "valid file at workspace root level", + run({ file_path: validFile, workspace_roots: [projectRoot] }).status, + 0, +); + +assert( + "valid file in nested subdirectory", + run({ file_path: nestedFile, workspace_roots: [projectRoot] }).status, + 0, +); + +const cursorInvalidResult = run({ file_path: invalidFile, workspace_roots: [projectRoot] }); +assert( + "invalid file reaches checker (exit 1)", + cursorInvalidResult.status, + 1, +); + +assert( + "checker diagnostics forwarded to stderr", + cursorInvalidResult.stderr.length > 0, + true, +); + +console.log("\nCursor — resilience:"); + +assert( + "missing workspace_roots falls back to cwd", + run({ file_path: validFile }).status, + 0, +); + +assert( + "empty workspace_roots falls back to cwd", + run({ file_path: validFile, workspace_roots: [] }).status, + 0, +); + +// --- Windsurf format: { tool_info: { file_path }, ... } --- +// Windsurf sends file_path inside tool_info. Working directory defaults to +// workspace root, so no workspace_roots equivalent — cwd is the fallback. + +console.log("\nWindsurf — early exit:"); + +assert( + "missing tool_info.file_path skipped", + run({ tool_info: {} }).status, + 0, +); + +assert( + "non-.allium extension skipped", + run({ tool_info: { file_path: path.join(projectRoot, "readme.md") } }, claudeEnv).status, + 0, +); + +console.log("\nWindsurf — accepted:"); + +assert( + "valid file via tool_info.file_path", + run({ tool_info: { file_path: validFile } }, claudeEnv).status, + 0, +); + +const windsurfInvalidResult = run({ tool_info: { file_path: invalidFile } }, claudeEnv); +assert( + "invalid file via tool_info.file_path reaches checker", + windsurfInvalidResult.status, + 1, +); + +// --- Format precedence --- + +console.log("\nFormat precedence:"); + +assert( + "tool_input.file_path wins over top-level file_path", + run({ tool_input: { file_path: invalidFile }, file_path: validFile, workspace_roots: [projectRoot] }, claudeEnv).status, + 1, +); + +assert( + "tool_input.file_path wins over tool_info.file_path", + run({ tool_input: { file_path: invalidFile }, tool_info: { file_path: validFile } }, claudeEnv).status, + 1, +); + +assert( + "top-level file_path used when tool_input has no file_path", + run({ tool_input: {}, file_path: invalidFile, workspace_roots: [projectRoot] }).status, + 1, +); + +assert( + "tool_info.file_path used when tool_input and file_path absent", + run({ tool_info: { file_path: invalidFile } }, claudeEnv).status, + 1, +); + +// --- Multiple workspace roots --- + +console.log("\nMultiple workspace roots:"); + +assert( + "file in first workspace root accepted", + run({ file_path: validFile, workspace_roots: [projectRoot, secondRoot] }).status, + 0, +); + +assert( + "file in second workspace root accepted", + run({ file_path: secondRootFile, workspace_roots: [projectRoot, secondRoot] }).status, + 0, +); + +const secondRootInvalidResult = run({ file_path: secondRootInvalid, workspace_roots: [projectRoot, secondRoot] }); +assert( + "invalid file in second workspace root reaches checker", + secondRootInvalidResult.status, + 1, +); + +assert( + "file outside all workspace roots rejected", + run({ file_path: outsideFile, workspace_roots: [projectRoot, secondRoot] }).status, + 0, +); + +// --- Root precedence --- +// Use invalid files so exit 1 = checker ran (accepted), exit 0 = rejected at boundary. + +console.log("\nRoot precedence:"); + +assert( + "file in CLAUDE_PROJECT_ROOT accepted even when workspace_roots points elsewhere", + run({ file_path: invalidFile, workspace_roots: [outsideDir] }, claudeEnv).status, + 1, +); + +assert( + "file in workspace_roots but not CLAUDE_PROJECT_ROOT also accepted (multi-root)", + run({ file_path: outsideFile, workspace_roots: [outsideDir] }, claudeEnv).status, + 0, +); + +// outsideFile is valid allium, so exit 0 above could be acceptance + valid check. +// Use a dedicated invalid file outside the project root to distinguish. +const outsideInvalid = path.join(outsideDir, "outside-bad.allium"); +writeFileSync(outsideInvalid, "this is not valid allium\n"); + +assert( + "invalid file in workspace_roots reaches checker when CLAUDE_PROJECT_ROOT also set", + run({ file_path: outsideInvalid, workspace_roots: [outsideDir] }, claudeEnv).status, + 1, +); + +// --- Malformed input --- + +console.log("\nMalformed input:"); + +function runRaw(rawStdin, env = {}) { + try { + execFileSync("node", [hook], { + input: rawStdin, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, ...env }, + }); + return { status: 0, stderr: "" }; + } catch (e) { + return { status: e.status, stderr: e.stderr || "" }; + } +} + +assert( + "invalid JSON exits cleanly", + runRaw("{not json}", claudeEnv).status, + 0, +); + +assert( + "empty stdin exits cleanly", + runRaw("", claudeEnv).status, + 0, +); + +// --- Root prefix confusion --- + +console.log("\nRoot prefix confusion:"); + +// Create /tmp/allium-hook-prefix- and /tmp/allium-hook-prefix-XYZ so one is a +// string prefix of the other (before the path separator). +const prefixBase = mkdtempSync(path.join(tmpdir(), "allium-hook-prefix-")); +const prefixExtended = mkdtempSync(path.join(tmpdir(), "allium-hook-prefix-")); +const prefixBaseFile = path.join(prefixBase, "a.allium"); +writeFileSync(prefixBaseFile, "-- allium: 3\n"); +const prefixExtendedFile = path.join(prefixExtended, "b.allium"); +writeFileSync(prefixExtendedFile, "-- allium: 3\n"); + +assert( + "file in root A not accepted under root B when A is a string prefix of B", + run({ file_path: prefixBaseFile, workspace_roots: [prefixExtended] }).status, + 0, +); + +assert( + "file in root B not accepted under root A when A is a string prefix of B", + run({ file_path: prefixExtendedFile, workspace_roots: [prefixBase] }).status, + 0, +); + +assert( + "both roots present, file in A accepted", + run({ file_path: prefixBaseFile, workspace_roots: [prefixBase, prefixExtended] }).status, + 0, +); + +assert( + "both roots present, file in B accepted", + run({ file_path: prefixExtendedFile, workspace_roots: [prefixBase, prefixExtended] }).status, + 0, +); + +// --- Trailing slash on roots --- + +console.log("\nTrailing slash on roots:"); + +assert( + "workspace_root with trailing slash still accepts file", + run({ file_path: validFile, workspace_roots: [projectRoot + "/"] }).status, + 0, +); + +assert( + "CLAUDE_PROJECT_ROOT with trailing slash still accepts file", + run({ tool_input: { file_path: validFile } }, { CLAUDE_PROJECT_ROOT: projectRoot + "/" }).status, + 0, +); + +assert( + "workspace_root with trailing slash still rejects outside file", + run({ file_path: outsideFile, workspace_roots: [projectRoot + "/"] }).status, + 0, +); + +// --- Empty string CLAUDE_PROJECT_ROOT --- + +console.log("\nEmpty CLAUDE_PROJECT_ROOT:"); + +assert( + "empty string CLAUDE_PROJECT_ROOT falls back to workspace_roots", + run({ file_path: validFile, workspace_roots: [projectRoot] }, { CLAUDE_PROJECT_ROOT: "" }).status, + 0, +); + +assert( + "empty string CLAUDE_PROJECT_ROOT without workspace_roots falls back to cwd", + run({ file_path: validFile }, { CLAUDE_PROJECT_ROOT: "" }).status, + 0, +); + +// --- All roots unresolvable --- + +console.log("\nAll roots unresolvable:"); + +assert( + "all workspace_roots invalid exits cleanly", + run({ file_path: validFile, workspace_roots: ["/nonexistent/a", "/nonexistent/b"] }).status, + 0, +); + +assert( + "CLAUDE_PROJECT_ROOT and workspace_roots all invalid exits cleanly", + run( + { file_path: validFile, workspace_roots: ["/nonexistent/a"] }, + { CLAUDE_PROJECT_ROOT: "/nonexistent/b" }, + ).status, + 0, +); + +// --- Symlinked workspace root --- + +console.log("\nSymlinked workspace root:"); + +const symlinkRoot = path.join(outsideDir, "link-to-project"); +symlinkSync(projectRoot, symlinkRoot); + +assert( + "file accepted when workspace_root is a symlink to the real root", + run({ file_path: validFile, workspace_roots: [symlinkRoot] }).status, + 0, +); + +assert( + "file outside symlinked root still rejected", + run({ file_path: outsideFile, workspace_roots: [symlinkRoot] }).status, + 0, +); + +// --- Spaces and special characters in paths --- + +console.log("\nSpecial characters in paths:"); + +const spacesDir = mkdtempSync(path.join(tmpdir(), "allium hook spaces-")); +const spacesFile = path.join(spacesDir, "my spec.allium"); +writeFileSync(spacesFile, "-- allium: 3\n"); +const spacesInvalid = path.join(spacesDir, "my broken spec.allium"); +writeFileSync(spacesInvalid, "this is not valid allium\n"); + +assert( + "file with spaces in path accepted", + run({ file_path: spacesFile, workspace_roots: [spacesDir] }).status, + 0, +); + +const spacesInvalidResult = run({ file_path: spacesInvalid, workspace_roots: [spacesDir] }); +assert( + "invalid file with spaces in path reaches checker", + spacesInvalidResult.status, + 1, +); + +const unicodeDir = mkdtempSync(path.join(tmpdir(), "allium-hök-ünïcödé-")); +const unicodeFile = path.join(unicodeDir, "spëc.allium"); +writeFileSync(unicodeFile, "-- allium: 3\n"); + +assert( + "file with unicode in path accepted", + run({ file_path: unicodeFile, workspace_roots: [unicodeDir] }).status, + 0, +); + +// --- Non-string file_path --- + +console.log("\nNon-string file_path:"); + +assert( + "numeric file_path exits cleanly", + run({ file_path: 42, workspace_roots: [projectRoot] }).status, + 0, +); + +assert( + "boolean file_path exits cleanly", + run({ file_path: true, workspace_roots: [projectRoot] }).status, + 0, +); + +assert( + "null file_path exits cleanly", + run({ file_path: null, workspace_roots: [projectRoot] }).status, + 0, +); + +assert( + "array file_path exits cleanly", + run({ file_path: [validFile], workspace_roots: [projectRoot] }).status, + 0, +); + +assert( + "numeric tool_input.file_path exits cleanly", + run({ tool_input: { file_path: 99 } }, claudeEnv).status, + 0, +); + +// --- Non-string workspace_roots entries --- + +console.log("\nNon-string workspace_roots entries:"); + +assert( + "numeric workspace_root skipped, valid root still works", + run({ file_path: validFile, workspace_roots: [42, projectRoot] }).status, + 0, +); + +assert( + "null workspace_root skipped, valid root still works", + run({ file_path: validFile, workspace_roots: [null, projectRoot] }).status, + 0, +); + +assert( + "all non-string workspace_roots exits cleanly", + run({ file_path: validFile, workspace_roots: [42, true, null] }).status, + 0, +); + +// --- workspace_roots not an array --- + +console.log("\nworkspace_roots not an array:"); + +assert( + "string workspace_roots exits cleanly", + run({ file_path: validFile, workspace_roots: projectRoot }).status, + 0, +); + +assert( + "numeric workspace_roots exits cleanly", + run({ file_path: validFile, workspace_roots: 42 }).status, + 0, +); + +assert( + "boolean workspace_roots exits cleanly", + run({ file_path: validFile, workspace_roots: true }).status, + 0, +); + +// --- Non-object tool_input --- + +console.log("\nNon-object tool_input:"); + +assert( + "string tool_input exits cleanly", + run({ tool_input: "hello" }, claudeEnv).status, + 0, +); + +assert( + "numeric tool_input exits cleanly", + run({ tool_input: 42 }, claudeEnv).status, + 0, +); + +assert( + "null tool_input exits cleanly", + run({ tool_input: null }, claudeEnv).status, + 0, +); + +// --- Empty string file_path --- + +console.log("\nEmpty string file_path:"); + +assert( + "empty string file_path exits cleanly", + run({ file_path: "", workspace_roots: [projectRoot] }).status, + 0, +); + +assert( + "empty string tool_input.file_path exits cleanly", + run({ tool_input: { file_path: "" } }, claudeEnv).status, + 0, +); + +// --- file_path equals root directory --- + +console.log("\nfile_path equals root:"); + +// Create a directory that ends in .allium so it passes the extension check +const rootAsFile = mkdtempSync(path.join(tmpdir(), "allium-hook-rootdir-")); +const alliumDir = path.join(rootAsFile, "specs.allium"); +mkdirSync(alliumDir); + +assert( + "file_path that is a directory exits cleanly", + run({ file_path: alliumDir, workspace_roots: [rootAsFile] }).status, + 0, +); + +assert( + "file_path equal to workspace root exits cleanly", + run({ file_path: rootAsFile + path.sep + "test.allium", workspace_roots: [rootAsFile] }).status, + 0, +); + +// Clean up +rmSync(projectRoot, { recursive: true }); +rmSync(outsideDir, { recursive: true }); +rmSync(secondRoot, { recursive: true }); +rmSync(prefixBase, { recursive: true }); +rmSync(prefixExtended, { recursive: true }); +rmSync(spacesDir, { recursive: true }); +rmSync(unicodeDir, { recursive: true }); +rmSync(rootAsFile, { recursive: true }); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0); diff --git a/plugins/allium/hooks/allium-lint.sh b/plugins/allium/hooks/allium-lint.sh new file mode 100755 index 0000000..5eea9c9 --- /dev/null +++ b/plugins/allium/hooks/allium-lint.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Wrapper for Aider's --lint-cmd. Aider passes the file path as a CLI argument. +# Only run allium check on .allium files; exit 0 for everything else. +case "$1" in + *.allium) exec allium check "$1" ;; + *) exit 0 ;; +esac diff --git a/plugins/allium/hooks/hooks.json b/plugins/allium/hooks/hooks.json new file mode 100644 index 0000000..754294d --- /dev/null +++ b/plugins/allium/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/allium-check.mjs\"" + } + ] + } + ] + } +} diff --git a/plugins/allium/scripts/generate-multi-editor.mjs b/plugins/allium/scripts/generate-multi-editor.mjs new file mode 100644 index 0000000..0665b75 --- /dev/null +++ b/plugins/allium/scripts/generate-multi-editor.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node + +/** + * Generates VS Code agent variants from the canonical Claude Code + * agent definitions in agents/. + * + * Skills (skills/tend/SKILL.md, skills/weed/SKILL.md) are hand-maintained + * independently of agents. Skills are interactive; agents are autonomous. + * The two diverge intentionally in tone and behaviour. + * + * Usage: node scripts/generate-multi-editor.mjs [--check] + * + * --check Report whether generated files are up to date without writing. + * Exits 1 if any file would change. + */ + +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; +import path from "path"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const CHECK = process.argv.includes("--check"); + +const AGENTS = ["tend", "weed"]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function read(rel) { + return readFileSync(path.join(ROOT, rel), "utf-8"); +} + +function write(rel, content) { + const abs = path.join(ROOT, rel); + mkdirSync(path.dirname(abs), { recursive: true }); + if (existsSync(abs) && readFileSync(abs, "utf-8") === content) return false; + if (!CHECK) writeFileSync(abs, content); + return true; +} + +function parseFrontmatter(src) { + const match = src.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) throw new Error("No frontmatter found"); + const fm = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + let val = line.slice(idx + 1).trim(); + if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); + fm[key] = val; + } + return { frontmatter: fm, body: match[2] }; +} + +function adaptBody(body) { + return ( + body + // Replace ${CLAUDE_PLUGIN_ROOT} paths with relative markdown links + .replace( + /`\$\{CLAUDE_PLUGIN_ROOT\}\/skills\/allium\/references\/language-reference\.md`/g, + "[language reference](../../skills/allium/references/language-reference.md)" + ) + .replace( + /`\$\{CLAUDE_PLUGIN_ROOT\}\/skills\/allium\/references\/assessing-specs\.md`/g, + "[assessing specs](../../skills/allium/references/assessing-specs.md)" + ) + .replace( + /`\$\{CLAUDE_PLUGIN_ROOT\}\/skills\/allium\/references\/actioning-findings\.md`/g, + "[actioning findings](../../skills/allium/references/actioning-findings.md)" + ) + // Replace Claude Code tool names with generic instructions + .replace(/\(use `Glob` to find them if not specified\)/g, "(search the project to find them if not specified)") + // Replace "agent" cross-references with "skill" for portable contexts + .replace(/the `weed` agent/g, "the `weed` skill") + .replace(/the `tend` agent/g, "the `tend` skill") + ); +} + +// --------------------------------------------------------------------------- +// VS Code agent generation +// --------------------------------------------------------------------------- + +function generateVscodeAgent(name) { + const src = read(`agents/${name}.md`); + const { frontmatter, body } = parseFrontmatter(src); + const adapted = adaptBody(body); + + // Omit tools — VS Code defaults to all available tools. + // Claude Code's Bash restriction (allium check *) can't be expressed + // in VS Code's format, so we accept broader tool access. + const agent = `--- +name: ${name} +description: "${frontmatter.description}" +--- +${adapted}`; + + return agent; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +let dirty = false; + +for (const name of AGENTS) { + if (write(`.github/agents/${name}.agent.md`, generateVscodeAgent(name))) { + console.log( + `${CHECK ? "out of date" : "wrote"}: .github/agents/${name}.agent.md` + ); + dirty = true; + } +} + +if (CHECK && dirty) { + console.error( + "\nGenerated files are out of date. Run: node scripts/generate-multi-editor.mjs" + ); + process.exit(1); +} + +if (!dirty) { + console.log("All generated files are up to date."); +} diff --git a/plugins/allium/scripts/test-skills.mjs b/plugins/allium/scripts/test-skills.mjs new file mode 100644 index 0000000..4a21587 --- /dev/null +++ b/plugins/allium/scripts/test-skills.mjs @@ -0,0 +1,585 @@ +#!/usr/bin/env node + +/** + * Validates that all skill and agent artifacts are structurally correct, + * correctly generated, properly isolated, and load in Claude Code. + * + * Usage: + * node scripts/test-skills.mjs # all offline tests + * node scripts/test-skills.mjs --live # include Claude Code smoke tests + * node scripts/test-skills.mjs structure # run one group + * node scripts/test-skills.mjs portability links # run multiple groups + * + * Groups: structure, codex, portability, links, routing, generation, discovery, crosstalk + * + * The first six groups are offline (free, fast). The last two require --live + * and make Claude API calls. + */ + +import { readFileSync, existsSync } from "fs"; +import { execFileSync, execSync } from "child_process"; +import path from "path"; + +let _claudePath; +function getClaudePath() { + if (!_claudePath) _claudePath = execSync("which claude", { encoding: "utf-8" }).trim(); + return _claudePath; +} + +const ROOT = path.resolve(import.meta.dirname, ".."); +const LIVE = process.argv.includes("--live"); + +// Parse group filters from positional args (ignore flags) +const requestedGroups = process.argv + .slice(2) + .filter((a) => !a.startsWith("--")); + +let passed = 0; +let failed = 0; +let skipped = 0; + +function pass(name) { + console.log(` pass: ${name}`); + passed++; +} + +function fail(name, detail) { + console.log(` FAIL: ${name}${detail ? ` — ${detail}` : ""}`); + failed++; +} + +function skip(name, reason) { + console.log(` skip: ${name} — ${reason}`); + skipped++; +} + +function rel(absPath) { + return path.relative(ROOT, absPath); +} + +function shouldRun(group) { + return requestedGroups.length === 0 || requestedGroups.includes(group); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseFrontmatter(src) { + const match = src.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) return null; + const fm = {}; + const lines = match[1].split("\n"); + let currentKey = null; + for (const line of lines) { + if (/^\s+-\s/.test(line) && currentKey) { + if (!Array.isArray(fm[currentKey])) fm[currentKey] = []; + fm[currentKey].push(line.replace(/^\s+-\s*/, "").trim()); + continue; + } + const idx = line.indexOf(":"); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + let val = line.slice(idx + 1).trim(); + if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); + currentKey = key; + fm[key] = val || true; + } + return { frontmatter: fm, body: match[2] }; +} + +function resolveRelativeLinks(body, fileDir) { + const linkPattern = /\[.*?\]\((\.\.?\/[^)]+)\)/g; + const links = []; + let m; + while ((m = linkPattern.exec(body)) !== null) { + links.push(m[1]); + } + return links.map((link) => ({ + link, + target: path.resolve(fileDir, link.replace(/#.*$/, "")), + exists: existsSync(path.resolve(fileDir, link.replace(/#.*$/, ""))), + })); +} + +function claudeQuery(prompt, { cwd } = {}) { + const output = execFileSync( + getClaudePath(), + [ + "--plugin-dir", ROOT, + "--print", + "--model", "haiku", + "--max-budget-usd", "0.05", + prompt, + ], + { + encoding: "utf-8", + timeout: 60000, + stdio: ["pipe", "pipe", "pipe"], + ...(cwd ? { cwd } : {}), + } + ); + // Strip markdown code fences and try to extract JSON + const cleaned = output.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim(); + // Try object first (greedy), then array + for (const re of [/\{[\s\S]*\}/, /\[[\s\S]*\]/]) { + const match = cleaned.match(re); + if (match) { + try { return JSON.parse(match[0]); } catch { /* try next */ } + } + } + throw new Error(`No valid JSON in response: ${output.slice(0, 200)}`); +} + +// Known paths +const skillNames = ["allium", "distill", "elicit", "propagate", "tend", "weed"]; +const skillPaths = skillNames.map((n) => path.join(ROOT, "skills", n, "SKILL.md")); +const agentPaths = ["tend", "weed"].map((n) => path.join(ROOT, "agents", `${n}.md`)); +const vscodeAgentPaths = ["tend", "weed"].map((n) => path.join(ROOT, ".github", "agents", `${n}.agent.md`)); +const codexPluginPath = path.join(ROOT, ".codex-plugin", "plugin.json"); +const portableSkillNames = ["tend", "weed"]; + +// Patterns that should not appear in portable artifacts +const CLAUDE_CODE_LEAKS = [ + [/\buse `Glob`\b/, "Glob"], + [/\buse `Grep`\b/, "Grep"], + [/\bBash\(allium check\b/, "Bash(allium check)"], + [/\$\{CLAUDE_PLUGIN_ROOT\}/, "${CLAUDE_PLUGIN_ROOT}"], + [/the `\w+` agent\b/, "agent cross-reference (should be 'skill')"], +]; + +function checkLeaks(body) { + return CLAUDE_CODE_LEAKS.filter(([re]) => re.test(body)).map(([, name]) => name); +} + +function readJson(filePath) { + try { + return JSON.parse(readFileSync(filePath, "utf-8")); + } catch (e) { + fail(rel(filePath), `invalid JSON: ${e.message}`); + return null; + } +} + +function isObject(value) { + return value && typeof value === "object" && !Array.isArray(value); +} + +// --------------------------------------------------------------------------- +// Structure — frontmatter validity for all artifact types +// --------------------------------------------------------------------------- + +if (shouldRun("structure")) { + console.log("\n── structure: frontmatter validation ──\n"); + + for (const skillPath of skillPaths) { + const label = rel(skillPath); + if (!existsSync(skillPath)) { fail(label, "file not found"); continue; } + const parsed = parseFrontmatter(readFileSync(skillPath, "utf-8")); + if (!parsed) { fail(label, "no valid frontmatter"); continue; } + const { frontmatter } = parsed; + if (!frontmatter.name) fail(`${label}`, "missing 'name'"); + else if (!frontmatter.description) fail(`${label}`, "missing 'description'"); + else pass(`${label}`); + } + + console.log(""); + + for (const agentPath of agentPaths) { + const label = rel(agentPath); + if (!existsSync(agentPath)) { fail(label, "file not found"); continue; } + const parsed = parseFrontmatter(readFileSync(agentPath, "utf-8")); + if (!parsed) { fail(label, "no valid frontmatter"); continue; } + const missing = ["name", "description", "model", "tools"].filter((k) => !parsed.frontmatter[k]); + if (missing.length > 0) fail(`${label}`, `missing: ${missing.join(", ")}`); + else pass(`${label}`); + } + + console.log(""); + + for (const agentPath of vscodeAgentPaths) { + const label = rel(agentPath); + if (!existsSync(agentPath)) { fail(label, "file not found"); continue; } + const parsed = parseFrontmatter(readFileSync(agentPath, "utf-8")); + if (!parsed) { fail(label, "no valid frontmatter"); continue; } + const { frontmatter } = parsed; + if (!frontmatter.name || !frontmatter.description) { + fail(`${label}`, "missing name or description"); + } else { + pass(`${label}`); + } + // VS Code doesn't support model or tools + const unsupported = ["model", "tools"].filter((k) => frontmatter[k]); + if (unsupported.length > 0) { + fail(`${label} vs-code compat`, `unsupported fields: ${unsupported.join(", ")}`); + } else { + pass(`${label} vs-code compat`); + } + // Naming convention + if (!path.basename(agentPath).endsWith(".agent.md")) { + fail(`${label} naming`, "must end with .agent.md"); + } else { + pass(`${label} naming`); + } + } +} + +// --------------------------------------------------------------------------- +// Codex — plugin manifest stays installable by Codex +// --------------------------------------------------------------------------- + +if (shouldRun("codex")) { + console.log("\n── codex: plugin manifest validation ──\n"); + + if (!existsSync(codexPluginPath)) { + fail(".codex-plugin/plugin.json", "file not found"); + } else { + const manifest = readJson(codexPluginPath); + + if (manifest) { + const requiredTopLevel = ["name", "version", "description", "author", "skills", "interface"]; + const missing = requiredTopLevel.filter((key) => !manifest[key]); + if (missing.length > 0) { + fail(".codex-plugin/plugin.json", `missing: ${missing.join(", ")}`); + } else { + pass(".codex-plugin/plugin.json required fields"); + } + + if (manifest.name === "allium") pass("codex plugin name"); + else fail("codex plugin name", `expected allium, got ${manifest.name}`); + + if (/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(manifest.version || "")) { + pass("codex plugin version"); + } else { + fail("codex plugin version", "must be strict semver"); + } + + if (manifest.skills === "./skills/") { + pass("codex skills path"); + } else { + fail("codex skills path", "must be ./skills/"); + } + + const skillsDir = path.join(ROOT, "skills"); + if (existsSync(skillsDir)) { + pass("codex skills directory exists"); + } else { + fail("codex skills directory", "skills/ not found"); + } + + const unsupported = ["agents", "hooks", "lspServers"].filter((key) => key in manifest); + if (unsupported.length > 0) { + fail(".codex-plugin/plugin.json", `unsupported fields: ${unsupported.join(", ")}`); + } else { + pass("codex manifest has no Claude-only fields"); + } + + if (isObject(manifest.interface)) { + const requiredInterface = [ + "displayName", + "shortDescription", + "longDescription", + "developerName", + "category", + "capabilities", + ]; + const missingInterface = requiredInterface.filter((key) => !manifest.interface[key]); + if (missingInterface.length > 0) { + fail("codex interface", `missing: ${missingInterface.join(", ")}`); + } else { + pass("codex interface required fields"); + } + + if ( + !manifest.interface.websiteURL || + /^https:\/\//.test(manifest.interface.websiteURL) + ) { + pass("codex interface websiteURL"); + } else { + fail("codex interface websiteURL", "must be an https URL"); + } + + const prompts = manifest.interface.defaultPrompt || []; + if ( + Array.isArray(prompts) && + prompts.length <= 3 && + prompts.every((p) => typeof p === "string" && p.length <= 128) + ) { + pass("codex default prompts"); + } else { + fail("codex default prompts", "must be at most 3 strings of 128 chars"); + } + } else { + fail("codex interface", "must be an object"); + } + } + } +} + +// --------------------------------------------------------------------------- +// Portability — no Claude Code references in portable artifacts +// --------------------------------------------------------------------------- + +if (shouldRun("portability")) { + console.log("\n── portability: no Claude Code leakage ──\n"); + + // All skills must not contain unexpanded placeholders + for (const skillPath of skillPaths) { + if (!existsSync(skillPath)) continue; + const parsed = parseFrontmatter(readFileSync(skillPath, "utf-8")); + if (!parsed) continue; + const label = rel(skillPath); + if (parsed.body.includes("${CLAUDE_PLUGIN_ROOT}")) { + fail(`${label}`, "contains unexpanded ${CLAUDE_PLUGIN_ROOT}"); + } else { + pass(`${label} no placeholders`); + } + } + + console.log(""); + + // Portable skills and VS Code agents must not reference Claude Code tools + const portableArtifacts = [ + ...portableSkillNames.map((n) => path.join(ROOT, "skills", n, "SKILL.md")), + ...vscodeAgentPaths, + ]; + for (const filePath of portableArtifacts) { + if (!existsSync(filePath)) continue; + const parsed = parseFrontmatter(readFileSync(filePath, "utf-8")); + if (!parsed) continue; + const leaks = checkLeaks(parsed.body); + const label = rel(filePath); + if (leaks.length > 0) { + fail(`${label}`, `Claude Code references: ${leaks.join(", ")}`); + } else { + pass(`${label}`); + } + } +} + +// --------------------------------------------------------------------------- +// Links — all relative markdown links resolve to real files +// --------------------------------------------------------------------------- + +if (shouldRun("links")) { + console.log("\n── links: relative link resolution ──\n"); + + const allPaths = [...skillPaths, ...agentPaths, ...vscodeAgentPaths]; + for (const filePath of allPaths) { + if (!existsSync(filePath)) continue; + const parsed = parseFrontmatter(readFileSync(filePath, "utf-8")); + if (!parsed) continue; + const links = resolveRelativeLinks(parsed.body, path.dirname(filePath)); + const broken = links.filter((l) => !l.exists); + for (const { link } of broken) { + fail(`${rel(filePath)}`, `broken link: ${link}`); + } + if (broken.length === 0) { + pass(`${rel(filePath)} (${links.length} link${links.length !== 1 ? "s" : ""})`); + } + } +} + +// --------------------------------------------------------------------------- +// Routing — allium SKILL.md routing table matches actual skill directories +// --------------------------------------------------------------------------- + +if (shouldRun("routing")) { + console.log("\n── routing: skill routing table ──\n"); + + const rootSkillPath = path.join(ROOT, "skills", "allium", "SKILL.md"); + const rootSrc = readFileSync(rootSkillPath, "utf-8"); + const routingRefs = [...rootSrc.matchAll(/`(\w+)` skill/g)].map((m) => m[1]); + for (const name of routingRefs) { + if (name === "this") continue; + const target = path.join(ROOT, "skills", name, "SKILL.md"); + if (existsSync(target)) { + pass(`${name}`); + } else { + fail(`${name}`, "skill directory not found"); + } + } + + // Reverse check: every skill directory should be referenced in the routing table + for (const name of skillNames.filter((n) => n !== "allium")) { + if (routingRefs.includes(name)) { + pass(`${name} in routing table`); + } else { + fail(`${name}`, "skill exists but not in routing table"); + } + } +} + +// --------------------------------------------------------------------------- +// Generation — generated files match what the script would produce +// --------------------------------------------------------------------------- + +if (shouldRun("generation")) { + console.log("\n── generation: roundtrip check ──\n"); + + try { + execFileSync( + "node", + [path.join(ROOT, "scripts", "generate-multi-editor.mjs"), "--check"], + { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] } + ); + pass("generated files up to date"); + } catch { + fail("generated files out of date", "run: node scripts/generate-multi-editor.mjs"); + } +} + +// --------------------------------------------------------------------------- +// Discovery — live Claude Code skill and agent loading +// --------------------------------------------------------------------------- + +if (shouldRun("discovery")) { + console.log("\n── discovery: Claude Code skill/agent loading ──\n"); + + if (!LIVE) { + skip("skill discovery", "pass --live to enable (uses API tokens)"); + skip("agent discovery", "pass --live to enable"); + } else { + try { + const skills = claudeQuery( + "List every allium skill available to you. Output ONLY a JSON array of " + + 'skill names without the allium: prefix, e.g. ["foo","bar"]. No other text.' + ); + const missing = skillNames.filter((s) => !skills.includes(s)); + const extra = skills.filter((s) => !skillNames.includes(s)); + if (missing.length > 0) fail("skill discovery", `missing: ${missing.join(", ")}`); + else if (extra.length > 0) fail("skill discovery", `unexpected: ${extra.join(", ")}`); + else pass(`skill discovery (${skills.length} skills)`); + } catch (e) { + fail("skill discovery", e.message?.slice(0, 200)); + } + + try { + const agents = claudeQuery( + "List every allium agent (subagent_type) available to you via the Agent tool. " + + 'Output ONLY a JSON array of agent names, e.g. ["foo","bar"]. No other text.' + ); + const expectedAgents = ["tend", "weed"]; + const missing = expectedAgents.filter((a) => !agents.includes(a)); + if (missing.length > 0) fail("agent discovery", `missing: ${missing.join(", ")}`); + else pass(`agent discovery (${agents.length} agents)`); + } catch (e) { + fail("agent discovery", e.message?.slice(0, 200)); + } + } +} + +// --------------------------------------------------------------------------- +// Crosstalk — skills from the plugin don't bleed into unrelated projects, +// and local agents/ don't leak outside the repo +// --------------------------------------------------------------------------- + +if (shouldRun("crosstalk")) { + console.log("\n── crosstalk: isolation between contexts ──\n"); + + if (!LIVE) { + skip("crosstalk", "pass --live to enable (uses API tokens)"); + } else { + // From a neutral directory (/tmp), only plugin-provided skills should + // appear. Local agents/ from the allium repo must not leak. + // Note: plugin agents only load in the project where the plugin is + // installed, so from /tmp we expect skills but not agents. + try { + const result = claudeQuery( + "List EVERY skill available to you that contains 'tend' or 'weed' in the name. " + + 'Output ONLY a JSON array of their exact names, e.g. ["allium:tend"]. No other text.', + { cwd: "/tmp" } + ); + + // Unprefixed names would mean local agents/ leaked + const unprefixed = result.filter((s) => s === "tend" || s === "weed"); + if (unprefixed.length > 0) { + fail("neutral dir", `local artifacts leaked: ${unprefixed.join(", ")}`); + } else { + pass("neutral dir: no local artifact bleed"); + } + + // Prefixed plugin skills should be present + const prefixed = result.filter( + (s) => s === "allium:tend" || s === "allium:weed" + ); + if (prefixed.length >= 2) { + pass("neutral dir: plugin skills present"); + } else { + fail("neutral dir: plugin skills", `expected allium:tend and allium:weed, got: ${result.join(", ")}`); + } + } catch (e) { + fail("neutral dir", e.message?.slice(0, 200)); + } + + // From inside the allium repo, both plugin skills (allium:tend) and + // local agents (tend) should be present. This is expected and correct: + // contributors working on the repo need the local agents. + try { + const result = claudeQuery( + "List EVERY skill AND agent (subagent_type) available to you that contains " + + "'tend' or 'weed'. Output ONLY a JSON object: " + + '{"skills": [...], "agents": [...]}. Exact names. No other text.', + { cwd: ROOT } + ); + + const { skills = [], agents = [] } = result; + + // Plugin skills should be present + const pluginSkills = skills.filter( + (s) => s === "allium:tend" || s === "allium:weed" + ); + if (pluginSkills.length >= 2) { + pass("allium repo: plugin skills present"); + } else { + fail("allium repo: plugin skills", `expected allium:tend and allium:weed, got: ${skills.join(", ")}`); + } + + // Local agents should also be present (from agents/) + const localAgents = agents.filter((a) => a === "tend" || a === "weed"); + if (localAgents.length >= 2) { + pass("allium repo: local agents present"); + } else { + // Not a failure, just informational — depends on plugin install state + skip("allium repo: local agents", `got: ${agents.join(", ") || "(none)"}`); + } + + // There should NOT be unprefixed tend/weed as skills (that would + // mean skills and agents are colliding) + const unprefixedSkills = skills.filter( + (s) => s === "tend" || s === "weed" + ); + if (unprefixedSkills.length > 0) { + fail("allium repo: skill/agent collision", `unprefixed skills: ${unprefixedSkills.join(", ")}`); + } else { + pass("allium repo: no skill/agent collision"); + } + } catch (e) { + fail("allium repo", e.message?.slice(0, 200)); + } + + // Advisory: warn about global plugin installation + try { + const listOutput = execSync("claude plugin list", { encoding: "utf-8" }); + if (/allium.*enabled/i.test(listOutput)) { + console.log( + "\n note: allium plugin is installed. Crosstalk tests account for this.\n" + + " For full isolation, disable it temporarily:\n" + + " claude plugin disable allium\n" + + " node scripts/test-skills.mjs --live crosstalk\n" + + " claude plugin enable allium" + ); + } + } catch { + // Not critical + } + } +} + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +console.log(`\n${"─".repeat(40)}`); +console.log(`${passed} passed, ${failed} failed, ${skipped} skipped`); +process.exit(failed > 0 ? 1 : 0); diff --git a/plugins/allium/skills/allium/SKILL.md b/plugins/allium/skills/allium/SKILL.md new file mode 100644 index 0000000..60ab538 --- /dev/null +++ b/plugins/allium/skills/allium/SKILL.md @@ -0,0 +1,311 @@ +--- +name: allium +description: Give your AI agents something more useful than a prompt. Velocity through clarity. +version: 3 +auto_trigger: + - file_patterns: ["**/*.allium"] + - keywords: ["allium", "allium spec", "allium specification", ".allium file"] +--- + +# Allium + +Allium is a formal language for capturing software behaviour at the domain level. It sits between informal feature descriptions and implementation, providing a precise way to specify what software does without prescribing how it's built. + +The name comes from the botanical family containing onions and shallots, continuing a tradition in behaviour specification tooling established by Cucumber and Gherkin. + +Key principles: + +- Describes observable behaviour, not implementation +- Captures domain logic that matters at the behavioural level +- Generates integration and end-to-end tests (not unit tests) +- Forces ambiguities into the open before implementation +- Implementation-agnostic: the same spec could be implemented in any language + +Allium does NOT specify programming language or framework choices, database schemas or storage mechanisms, API designs or UI layouts, or internal algorithms (unless they are domain-level concerns). + +## Routing table + +| Task | Tool | When | +|------|------|------| +| Writing or reading `.allium` files | this skill | You need language syntax and structure | +| Building a spec through conversation | `elicit` skill | User describes a feature or behaviour they want to build | +| Extracting a spec from existing code | `distill` skill | User has implementation code and wants a spec from it | +| Modifying an existing spec | `tend` skill | User wants targeted changes to `.allium` files | +| Checking spec-to-code alignment | `weed` skill | User wants to find or fix divergences between spec and implementation | +| Generating tests from a spec | `propagate` skill | User wants to generate tests, PBT properties or state machine tests from a specification | + +## Quick syntax summary + +### Entity + +``` +entity Candidacy { + -- Fields + candidate: Candidate + role: Role + status: pending | active | completed | cancelled -- inline enum + retry_count: Integer + + -- Relationships + invitation: Invitation with candidacy = this -- one-to-one + slots: InterviewSlot with candidacy = this -- one-to-many + + -- Projections + confirmed_slots: slots where status = confirmed + pending_slots: slots where status = pending + + -- Derived + is_ready: confirmed_slots.count >= 3 + has_expired: invitation.expires_at <= now +} +``` + +### External entity + +``` +external entity Role { title: String, required_skills: Set, location: Location } +``` + +### Value type + +``` +value TimeRange { start: Timestamp, end: Timestamp, duration: end - start } +``` + +### Sum type + +A base entity declares a discriminator field whose capitalised values name the variants. Variants use the `variant` keyword. + +``` +entity Node { + path: Path + kind: Branch | Leaf -- discriminator field +} + +variant Branch : Node { + children: List +} + +variant Leaf : Node { + data: List + log: List +} +``` + +Lowercase pipe values are enum literals (`status: pending | active`). Capitalised values are variant references (`kind: Branch | Leaf`). Type guards (`requires:` or `if` branches) narrow to a variant and unlock its fields. + +### Module given + +Declares the entity instances a module's rules operate on. All rules inherit these bindings. Not every module needs one: rules scoped by triggers on domain entities get their entities from the trigger. `given` is for specs where rules operate on shared instances that exist once per module scope. + +``` +given { + pipeline: HiringPipeline + calendar: InterviewCalendar +} +``` + +Imported module instances are accessed via qualified names (`scheduling/calendar`) and do not appear in the local `given` block. Distinct from surface `context`, which binds a parametric scope for a boundary contract. + +### Rule + +``` +rule InvitationExpires { + when: invitation: Invitation.expires_at <= now + requires: invitation.status = pending + let remaining = invitation.proposed_slots where status != cancelled + ensures: invitation.status = expired + ensures: + for s in remaining: + s.status = cancelled + @guidance + -- Non-normative implementation advice. +} +``` + +### Trigger types + +- **External stimulus**: `when: CandidateSelectsSlot(invitation, slot)` — action from outside the system +- **State transition**: `when: interview: Interview.status transitions_to scheduled` — entity changed state (transition only, not creation) +- **State becomes**: `when: interview: Interview.status becomes scheduled` — entity has this value, whether by creation or transition +- **Temporal**: `when: invitation: Invitation.expires_at <= now` — time-based condition (always add a `requires` guard against re-firing) +- **Derived condition**: `when: interview: Interview.all_feedback_in` — derived value becomes true +- **Entity creation**: `when: batch: DigestBatch.created` — fires when a new entity is created +- **Chained**: `when: AllConfirmationsResolved(candidacy)` — subscribes to a trigger emission from another rule's ensures clause + +All entity-scoped triggers use explicit `var: Type` binding. Use `_` as a discard binding where the name is not needed: `when: _: Invitation.expires_at <= now`, `when: SomeEvent(_, slot)`. + +### Rule-level iteration + +A `for` clause applies the rule body once per element in a collection: + +``` +rule ProcessDigests { + when: schedule: DigestSchedule.next_run_at <= now + for user in Users where notification_setting.digest_enabled: + let settings = user.notification_setting + ensures: DigestBatch.created(user: user, ...) +} +``` + +### Ensures patterns + +Ensures clauses have four outcome forms: + +- **State changes**: `entity.field = value` +- **Entity creation**: `Entity.created(...)` — the single canonical creation verb +- **Trigger emission**: `TriggerName(params)` — emits an event for other rules to chain from +- **Entity removal**: `not exists entity` — asserts the entity no longer exists + +These forms compose with `for` iteration (`for x in collection: ...`), `if`/`else` conditionals and `let` bindings. + +Entity creation uses `.created()` exclusively. Domain meaning lives in entity names and rule names, not in creation verbs. + +In state change assignments, the right-hand expression references pre-rule field values. Conditions within ensures blocks (`if` guards, creation parameters, trigger emission parameters) reference the resulting state. + +### Surface + +``` +surface InterviewerDashboard { + facing viewer: Interviewer + + context assignment: SlotConfirmation where interviewer = viewer + + exposes: + assignment.slot.time + assignment.status + + provides: + InterviewerConfirmsSlot(viewer, assignment.slot) + when assignment.status = pending + + related: + InterviewDetail(assignment.slot.interview) + when assignment.slot.interview != null +} +``` + +Surfaces define contracts at boundaries. The `facing` clause names the external party, `context` scopes the entity. The remaining clauses use a single vocabulary regardless of whether the boundary is user-facing or code-to-code: `exposes` (visible data, supports `for` iteration over collections), `provides` (available operations with optional when-guards), `contracts:` (references module-level `contract` declarations with `demands`/`fulfils` direction markers), `@guarantee` (named prose assertions about the boundary), `@guidance` (non-normative advice), `related` (associated surfaces reachable from this one), `timeout` (references to temporal rules that apply within the surface's context). + +The `facing` clause accepts either an actor type (with a corresponding `actor` declaration and `identified_by` mapping) or an entity type directly. Use actor declarations when the boundary has specific identity requirements; use entity types when any instance can interact (e.g., `facing visitor: User`). For integration surfaces where the external party is code, declare an actor type with a minimal `identified_by` expression. Actors that reference `within` in their `identified_by` expression must declare the expected context type: `within: Workspace`. + +### Surface-to-implementation contract + +The `exposes` block is the field-level contract: the implementation returns exactly these fields, the consumer uses exactly these fields. Do not add fields not listed. Do not omit fields that are listed. + +### Contract + +```allium +contract Codec { + serialize: (value: Any) -> ByteArray + deserialize: (bytes: ByteArray) -> Any + + @invariant Roundtrip + -- deserialize(serialize(value)) produces a value + -- equivalent to the original for all supported types. +} +``` + +Contracts are module-level declarations referenced by name in surface `contracts:` clauses (`demands Codec`, `fulfils EventSubmitter`). See [Contracts](./references/language-reference.md#contracts) for declaration syntax and referencing rules. + +### Expressions + +Navigation: `interview.candidacy.candidate.email`, `reply_to?.author` (optional), `timezone ?? "UTC"` (null coalescing). Collections: `slots.count`, `slot in invitation.slots`, `interviewers.any(i => i.can_solo)`, `for item in collection: item.status = cancelled`, `permissions + inherited` (set union), `old - new` (set difference). Comparisons: `status = pending`, `count >= 2`, `status in {confirmed, declined}`, `provider not in providers`. Boolean logic: `a and b`, `a or b`, `not a`, `a implies b`. + +### Modular specs + +``` +use "github.com/allium-specs/google-oauth/abc123def" as oauth +``` + +Qualified names reference entities across specs: `oauth/Session`. Coordinates are immutable (git SHAs or content hashes). Local specs use relative paths: `use "./candidacy.allium" as candidacy`. + +### Config + +``` +config { + invitation_expiry: Duration = 7.days + max_login_attempts: Integer = 5 + extended_expiry: Duration = invitation_expiry * 2 -- expression-form default + sync_timeout: Duration = core/config.default_timeout -- config parameter reference +} +``` + +Rules reference config values as `config.invitation_expiry`. For default entity instances, use `default`. + +### Defaults + +``` +default Role viewer = { name: "viewer", permissions: { "documents.read" } } +``` + +### Invariant + +```allium +invariant NonNegativeBalance { + for account in Accounts: + account.balance >= 0 +} +``` + +Expression-bearing invariants (`invariant Name { expression }`) assert properties over entity state. They are logical assertions, not runtime checks. Distinct from prose annotations (`@invariant Name`) in contracts, which use the `@` sigil to mark content the checker does not evaluate. See [Invariants](./references/language-reference.md#invariants). + +### Transition graph (v3) + +``` +entity Order { + status: pending | confirmed | shipped | delivered | cancelled + + transitions status { + pending -> confirmed + confirmed -> shipped + shipped -> delivered + pending -> cancelled + confirmed -> cancelled + terminal: delivered, cancelled + } +} +``` + +### State-dependent field presence (v3) + +``` +entity Order { + status: pending | confirmed | shipped | delivered | cancelled + customer: Customer + total: Money + tracking_number: String when status = shipped | delivered + shipped_at: Timestamp when status = shipped | delivered + + transitions status { + pending -> confirmed + confirmed -> shipped + shipped -> delivered + pending -> cancelled + confirmed -> cancelled + terminal: delivered, cancelled + } +} +``` + +### Deferred specs + +``` +deferred InterviewerMatching.suggest -- see: detailed/interviewer-matching.allium +``` + +### Open questions + +``` +open question "Admin ownership - should admins be assigned to specific roles?" +``` + +## Verification + +When the `allium` CLI is installed, a hook validates `.allium` files automatically after every write or edit. Fix any reported issues before presenting the result. If the CLI is not available, verify against the [language reference](./references/language-reference.md). + +## References + +- [Language reference](./references/language-reference.md) — full syntax for entities, rules, expressions, surfaces, contracts, invariants and validation +- [Test generation](./references/test-generation.md) — generating tests from specifications +- [Patterns](./references/patterns.md) — 9 worked patterns: auth, RBAC, invitations, soft delete, notifications, usage limits, comments, library spec integration, framework integration contract diff --git a/plugins/allium/skills/allium/references/actioning-findings.md b/plugins/allium/skills/allium/references/actioning-findings.md new file mode 100644 index 0000000..fbe7101 --- /dev/null +++ b/plugins/allium/skills/allium/references/actioning-findings.md @@ -0,0 +1,66 @@ +# Actioning findings + +When `allium analyse` produces findings, translate each into a domain question rather than presenting raw output. The user should never see finding types, evidence chains or JSON. They should hear a question that helps them improve their spec. + +## Finding types and question strategies + +### `missing_producer` + +A rule's `requires` clause references a value that nothing in the spec establishes. The data dependency is unsatisfied. + +**Ask about the source.** Work backward from the requirement: "The hiring decision needs the background check to be clear, but nothing in the spec says where background check results come from. Is this provided by an external service, or does someone enter it manually?" + +The `searched` field in the finding shows what the checker looked for. If it found a partial chain (a rule that could produce the value, but whose trigger is itself unreachable), follow the chain: "There's a rule to handle background check results, but nothing triggers it. How do results get into the system?" + +### `unreachable_trigger` + +A rule listens for a trigger that no surface provides and no other rule emits. The rule can never fire. + +**Ask about the entry point.** "This rule handles background check results, but nothing in the spec says where they come from. Is this a webhook from an external service? A screen where someone enters the result? Something else?" + +If the trigger name suggests an external system, prompt for whether it should be a surface (human-facing) or a contract integration point (system-facing). + +### `dead_transition` + +A transition is declared in the graph and witnessed by a rule, but the rule's guards can never be satisfied. The transition exists on paper but is impossible in practice. + +**Ask what's needed.** "The spec says a candidacy can move from screening to interviewing, but that requires the background check to be clear. I can't find a path through the spec that produces a clear background check. What needs to happen for this transition to work?" + +The finding's evidence shows which guard is unsatisfiable and why. Use this to frame the question in terms of what's missing, not what's broken. + +### `deadlock` + +A non-terminal state has no achievable exit. The entity can reach this state but can never leave it. + +**Ask what happens when things stall.** "If a candidacy reaches the screening state and the background check never completes, the candidacy is stuck. What should happen in that situation? Is there a timeout? Can someone manually override it?" + +If the finding includes cycle evidence (states that loop without reaching terminal), frame it differently: "The spec allows a job to bounce between retrying and waiting indefinitely without ever completing or failing. Is there a maximum number of retries, or a timeout that breaks the cycle?" + +### `conflict` + +Two rules with different triggers can both fire in the same state and would set the same field to different values. The outcome is ambiguous. + +**Ask about priority.** "If a membership is active and both the expiry timer fires and an admin extends it at the same moment, which should win? Should the extension prevent the expiry, or should the expiry take priority?" + +This is distinct from actor choice (where one actor picks between alternatives). Conflicts arise from independent triggers that the spec doesn't order. + +### `invariant_risk` + +A rule's `ensures` clause could produce a state that violates a declared invariant. The `requires` clause may not prevent it. + +**Ask whether to guard or revise.** "The spec says at most one candidate per role can be hired, but the hiring rule doesn't prevent a second hire if the role hasn't been marked as filled. Should we add a guard that checks the role is still open, or is the invariant too strict?" + +The finding's evidence shows the mechanism — how the ensures clause is inconsistent with the invariant. Use this to suggest a specific fix rather than asking an open-ended question. + +## Choosing which finding to present + +When `analyse` returns multiple findings, pick the most relevant one. Apply these criteria in order: + +1. If a finding chains into another (a `dead_transition` caused by a `missing_producer` caused by an `unreachable_trigger`), present the root cause first — even if it's in a different entity. Frame it in terms of its effect on the entity the user is working on. +2. If the user is working on a specific entity, pick a finding that affects that entity. +3. If the user just added a rule, pick a finding related to that rule's data flow. +4. If the user asked about completeness, pick the highest-impact finding first — deadlocks before broken data flow chains, broken chains before unreachable triggers. + +A `deadlock` or `invariant_risk` finding indicates the spec may be structurally unsound. Surface these before continuing to build on the affected entity — adding more rules to a deadlocked lifecycle compounds the problem. Other finding types (`missing_producer`, `unreachable_trigger`, `dead_transition`, `conflict`) are gaps worth resolving but don't necessarily block further work. + +Present one finding at a time. Let the user resolve it before surfacing the next. If `analyse` returns more than five or six findings, present the most impactful two or three individually, then summarise the rest by category: "There are also three unreachable triggers and two missing producers — would you like to work through those, or focus on something else?" diff --git a/plugins/allium/skills/allium/references/assessing-specs.md b/plugins/allium/skills/allium/references/assessing-specs.md new file mode 100644 index 0000000..78eb5a1 --- /dev/null +++ b/plugins/allium/skills/allium/references/assessing-specs.md @@ -0,0 +1,66 @@ +# Assessing specs + +When working with an Allium spec, assess its maturity before deciding what to do next. Spec maturity isn't uniform — a well-developed entity with full rules and surfaces can sit alongside a newly sketched entity with just a transition graph, in the same file. + +## Spec-level assessment + +Read the spec and note which constructs are present: + +| What's present | What it tells you | +|---|---| +| Entities with fields, no transition graphs | Domain concepts identified but lifecycles not yet explored | +| Transition graphs on entities | Lifecycles sketched — the user knows the states and intended flows | +| Rules witnessing transitions | Behaviour specified — triggers, guards and outcomes defined | +| Surfaces with exposes and provides | Boundaries defined — who sees what and can do what | +| Actors with identified_by | Roles identified and formalised | +| Invariants | Cross-cutting properties asserted | +| Open questions | Known unknowns documented | +| Deferred specifications | Complexity acknowledged and scoped for later | + +A spec with entities and transition graphs but no rules is coarse. The right next step is filling in rules ("what triggers this transition?"). A spec with rules but no surfaces has behaviour without boundaries. The right next step is asking about actors and what they see. + +## Per-entity assessment + +Each entity can be at a different level of development. Check: + +- **Has a transition graph?** The lifecycle is sketched. +- **Has witnessing rules for all transitions?** Every declared edge has a rule that produces it. +- **Has surfaces providing all external triggers?** Every rule that listens for an external stimulus has a surface that provides it. +- **Has all `requires` clauses traceable to a producer?** Every precondition can be satisfied by a prior rule or surface in the spec. + +An entity that has all four is structurally complete. It may still lack exception transitions, temporal triggers or failure paths — those are explored through obstacle elicitation, not structural assessment. An entity missing the fourth criterion has gaps the user may not be aware of. + +## When to use `check` vs `analyse` + +If the Allium CLI is available: + +The two commands produce different kinds of output. `check` produces **diagnostics**: line-level structural warnings (syntax issues, unreachable values, unused fields). `analyse` produces **findings**: process-level results with typed evidence (missing producers, dead transitions, deadlocks). Both are returned as JSON. See [actioning findings](actioning-findings.md) for how to translate findings into domain questions. + +Run `allium check` after every edit. It validates what's written — syntax, field resolution, transition graph structure, witnessing rules. It's fast and useful at every stage, including coarse specs. + +Run `allium analyse` at natural checkpoints: when the user asks about completeness, when at least one entity has both witnessing rules and surfaces defined, when transitioning from one entity to another, or when stepping back to review. It reasons about what's missing — data flow gaps, unreachable transitions, deadlocks. + +If the CLI is not available, fall back to the language reference for validation. The first time this fallback happens, note: "I'll validate against the language reference instead. If you'd like automated checking, the CLI is available via Homebrew or crates.io — see the README for details." + +If `allium analyse` fails with an unrecognised command error, the installed CLI predates the `analyse` feature. Fall back to conversational analysis (trace data flow and reachability by reading the spec) and don't retry `analyse` in the same session. Mention that updating the CLI would enable automated process-level checking. + +## Adjusting your approach + +Work at the right level for each part of the spec: + +- A coarse entity calls for walkthrough questions: "What triggers this transition? Who's involved at this step?" +- A detailed entity with rules calls for gap analysis: "This rule requires a value that nothing in the spec produces. Where does it come from?" +- A well-specified entity calls for validation: "Here's the lifecycle as I understand it — does this match your mental model?" + +Don't apply detailed analysis to a coarse spec (it produces noise about things that haven't been written yet). Don't ask exploratory questions about an entity that already has rules and surfaces covering all declared transitions, including exception paths (the user has already answered them). + +## Communicating with stakeholders + +Users are not expected to read or write Allium syntax. When discussing the spec with stakeholders, translate constructs into domain language: + +- Instead of showing a transition graph, describe the lifecycle: "A candidacy starts as applied, moves through screening and interviewing, and ends as either hired or rejected." +- Instead of showing a rule, describe the behaviour: "When the recruiter advances a candidate, the system checks that the background check is clear before moving to interviews." +- Instead of showing a surface, describe the interaction: "The recruiter sees a queue of candidates awaiting screening, with their name and the role they applied for." +- Instead of listing `open_questions`, pose them directly: "One thing we haven't resolved — what happens to in-progress candidacies when a role is closed?" + +When validating the spec, describe what it says and ask whether that matches expectations. Don't present the spec itself for review unless the user has shown they're comfortable reading it. The spec is the artefact; the conversation is in domain terms. diff --git a/plugins/allium/skills/allium/references/language-reference.md b/plugins/allium/skills/allium/references/language-reference.md new file mode 100644 index 0000000..13f639e --- /dev/null +++ b/plugins/allium/skills/allium/references/language-reference.md @@ -0,0 +1,2185 @@ +# Language reference + +## File structure + +An Allium specification file (`.allium`) begins with a language version marker, followed by these sections in order: + +``` +-- allium: 3 +-- Comments use double-dash +-- use declarations (optional) + +------------------------------------------------------------ +-- Given +------------------------------------------------------------ + +-- Entity instances this module operates on (optional) + +------------------------------------------------------------ +-- External Entities +------------------------------------------------------------ + +-- Entities managed outside this specification + +------------------------------------------------------------ +-- Value Types +------------------------------------------------------------ + +-- Structured data without identity (optional section) + +------------------------------------------------------------ +-- Contracts +------------------------------------------------------------ + +-- Reusable obligation contracts referenced by surfaces + +------------------------------------------------------------ +-- Enumerations +------------------------------------------------------------ + +-- Named enumerations shared across entities (optional section) + +------------------------------------------------------------ +-- Entities and Variants +------------------------------------------------------------ + +-- Entities managed by this specification, plus their variants + +------------------------------------------------------------ +-- Config +------------------------------------------------------------ + +-- Configurable parameters for this specification + +------------------------------------------------------------ +-- Defaults +------------------------------------------------------------ + +-- Default entity instances + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +-- Behavioural rules organised by flow + +------------------------------------------------------------ +-- Invariants +------------------------------------------------------------ + +-- System-wide and entity-level property assertions + +------------------------------------------------------------ +-- Actor Declarations +------------------------------------------------------------ + +-- Entity types that can interact with surfaces + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +-- Boundary contracts between parties + +------------------------------------------------------------ +-- Deferred Specifications +------------------------------------------------------------ + +-- References to detailed specs defined elsewhere + +------------------------------------------------------------ +-- Open Questions +------------------------------------------------------------ + +-- Unresolved design decisions +``` + +### Formatting + +Indentation is significant. Blocks opened by a colon (`:`) after `for`, `if`, `else`, `ensures`, `exposes`, `provides`, `contracts`, `related` and `timeout` are delimited by consistent indentation relative to the parent clause. Named blocks opened by a keyword and PascalCase name followed by `{ ... }` (`contract`, `invariant`) use brace delimiters. Prose annotations prefixed with `@` (`@invariant`, `@guarantee`, `@guidance`) are followed by indented comment lines that form their body. `contracts:` entries use `demands`/`fulfils` modifiers followed by a contract name. Comments use `--`. Commas may be used as field separators for single-line entity and value type declarations; newlines are the standard separator for multi-line declarations. + +### Naming conventions + +- **PascalCase**: entity names, variant names, rule names, trigger names, actor names, surface names, contract names, invariant names (`InterviewSlot`, `CandidateSelectsSlot`, `DeterministicEvaluation`, `Purity`) +- **snake_case**: field names, config parameters, derived values, enum literals, relationship names (`expires_at`, `max_login_attempts`, `pending`). Enum literals that reference external standards may use backtick-quoted forms containing characters outside the snake_case set (`` `de-CH-1996` ``, `` `no-cache` ``) +- **Entity collections**: natural English plurals of the entity name (`Users`, `Documents`, `Candidacies`) + +--- + +## Module given + +A `given` block declares the entity instances a module operates on. All rules in the module inherit these bindings. + +``` +given { + pipeline: HiringPipeline + calendar: InterviewCalendar +} +``` + +Rules then reference `pipeline.status`, `calendar.available_slots`, etc. without ambiguity about what they refer to. + +Not every module needs a `given` block. Rules scoped by triggers on domain entities (e.g., `when: invitation: Invitation.expires_at <= now`) get their entities from the trigger binding. `given` is for specs where rules operate on shared instances that exist once per module scope, such as a pipeline, a catalog or a processing engine. + +`given` bindings must reference entity types declared in the same module or imported via `use`. Imported module instances are accessed via qualified names (`scheduling/calendar`) and do not need to appear in the local `given` block. Modules that operate only on imported instances may omit the `given` block entirely. + +This is distinct from surface `context`, which binds a parametric scope for a boundary contract (e.g., `context assignment: SlotConfirmation`). + +--- + +## Contracts + +A `contract` declaration defines a named, direction-agnostic obligation at module level. Surfaces reference contracts in a `contracts:` clause using `demands` (the counterpart must implement) or `fulfils` (this surface supplies) direction markers. + +### Declaration syntax + +``` +contract Codec { + serialize: (value: Any) -> ByteArray + deserialize: (bytes: ByteArray) -> Any + + @invariant Roundtrip + -- deserialize(serialize(value)) produces a value + -- equivalent to the original for all supported types. + + @guidance + -- Implementations should handle versioned payloads + -- by inspecting a version prefix in the byte array. +} +``` + +Contract bodies contain typed signatures and annotations (`@invariant`, `@guidance`). Entity, value, enum and variant declarations are prohibited inside contracts. Types referenced in signatures must be declared at module level or imported via `use`. + +### Referencing contracts in surfaces + +Surfaces reference contracts in a `contracts:` clause. Each entry uses `demands` or `fulfils` to indicate the direction of the obligation: + +``` +surface DomainIntegration { + facing framework: FrameworkRuntime + + contracts: + demands Codec + demands DeterministicEvaluation + fulfils EventSubmitter + + @guarantee AllOperationsIdempotent + -- All operations exposed by this surface are safe to retry. +} +``` + +`@guarantee` is a surface-level prose assertion about the boundary as a whole; see [Surfaces](#surfaces) for the full clause reference. + +The surface inherits all signatures, invariants and guidance from each referenced contract. Each contract name may appear at most once per surface. + +### Contract identity + +Contract identity is determined by module-qualified name, consistent with entity and value type identity rules. Two contracts are "the same" if and only if they resolve to the same module-qualified declaration. Composed surfaces referencing the same module-qualified contract are not in conflict. Surfaces referencing identically named contracts from different modules are a structural error. + +### Imports + +Contracts are importable across modules via `use`, following the same coordinate system as entity imports. Contract imports are atomic: a contract is imported as a complete unit. Partial imports (importing individual signatures from a contract) are not supported. + +### No type parameters + +Contracts do not support type parameters. Signatures may use `Any` where type generality is needed, with invariants expressing the type relationships in prose. + +--- + +## Entities + +### External entities + +Entities referenced but managed outside this specification: + +``` +external entity Role { + title: String + required_skills: Set + location: Location +} +``` + +External entities define their structure but not their lifecycle. The specification checker will warn when external entities are referenced, reminding that another spec or system governs them. + +External entities can also serve as **type placeholders**: an entity with minimal or no fields that the consuming spec substitutes with a concrete type. This enables reusable patterns where the library spec depends on an abstraction and the consumer provides the implementation. + +``` +-- In a comments library spec +external entity Commentable {} + +entity Comment { + parent: Commentable + ... +} + +-- The consuming spec provides its own entity as the Commentable +``` + +The consuming spec maps its entity to the placeholder by using it wherever the library expects the placeholder type. This is dependency inversion at the spec level: the library depends on the abstraction, the consumer supplies the concrete type. + +### Internal entities + +``` +entity Candidacy { + -- Fields (required) + candidate: Candidate + role: Role + status: pending | active | completed | cancelled + + -- Relationships (navigate to related entities) + invitation: Invitation with candidacy = this + slots: InterviewSlot with candidacy = this + + -- Projections (filtered subsets) + confirmed_slots: slots where status = confirmed + pending_slots: slots where status = pending + + -- Derived (computed values) + is_ready: confirmed_slots.count >= 3 + has_expired: invitation.expires_at <= now +} +``` + +### Value types + +Structured data without identity. No lifecycle, compared by value not reference. Use for concepts such as time ranges and addresses. + +``` +value TimeRange { + start: Timestamp + end: Timestamp + + -- Derived + duration: end - start +} + +value Location { + name: String + timezone: String + country: String? +} +``` + +Value types have no identity, are immutable and are embedded within entities. Entities have identity, lifecycle and rules that govern them. + +### Sum types + +Sum types (discriminated unions) specify that an entity is exactly one of several alternatives. + +``` +entity Node { + path: Path + kind: Branch | Leaf -- discriminator field +} + +variant Branch : Node { + children: List -- variant-specific field +} + +variant Leaf : Node { + data: List -- variant-specific fields + log: List +} +``` + +A sum type has three parts: a **discriminator field** whose type is a pipe-separated list of variant names, **variant declarations** using `variant X : BaseEntity`, and **variant-specific fields** that only exist for that variant. Variants inherit all fields from the base entity; the discriminator is set automatically on creation. + +**Distinguishing sum types from enums:** unquoted lowercase values are enum literals (`status: pending | active`), unquoted capitalised values are variant references (`kind: Branch | Leaf`). Backtick-quoted values are always enum literals regardless of case (`` `de-CH-1996` ``). The validator checks that unquoted capitalised names correspond to `variant` declarations. + +**Creating variant instances** — always via the variant name, not the base: + +``` +ensures: MentionNotification.created(user: recipient, comment: comment, mentioned_by: author) +-- Not: Notification.created(...) -- Error: must specify which variant +``` + +**Type guards** narrow an entity to a specific variant, enabling access to its fields. They appear in `requires` clauses (guarding the entire rule) and `if` expressions (guarding a branch): + +``` +-- requires guard: entire rule assumes Leaf +rule ProcessLeaf { + when: ProcessNode(node) + requires: node.kind = Leaf + ensures: Results.created(data: node.data + node.log) +} + +-- if guard: branch-level narrowing +rule ProcessNode { + when: ProcessNode(node) + ensures: + if node.kind = Branch: + for child in node.children: ProcessNode(child) + else: + Results.created(data: node.data + node.log) +} +``` + +Accessing variant-specific fields outside a type guard is an error. Sum types guarantee exhaustiveness (all variants declared upfront), mutual exclusivity (exactly one variant), type safety (variant fields only within guards) and automatic discrimination (set on creation). + +A `.created` trigger on the base entity fires when any variant is created. The bound variable holds the specific variant instance, and type guards can narrow it: + +``` +rule HandleNotification { + when: notification: Notification.created + ensures: + if notification.kind = MentionNotification: + ... +} +``` + +Use sum types when variants have fundamentally different data or behaviour. Do not use when simple status enums suffice or variants share most of their structure. + +### Field types + +**Primitive types:** +- `String` — text +- `Integer` — whole numbers. Underscores are ignored in numeric literals for readability: `100_000_000` +- `Decimal` — numbers with fractional parts (use for money, percentages) +- `Boolean` — `true` or `false` +- `Timestamp` — point in time. The built-in value `now` evaluates to the current timestamp. Its evaluation model depends on context: in derived values, `now` re-evaluates on each read (making the derived value volatile); in ensures clauses, `now` is bound to the rule execution timestamp (a snapshot); in temporal triggers, `now` is the evaluation timestamp with fire-once semantics. +- `Duration` — length of time, written as a numeric literal with a unit suffix: `.seconds`, `.minutes`, `.hours`, `.days`, `.weeks`, `.months`, `.years` (e.g., `24.hours`, `7.days`, `30.seconds`). Both singular and plural forms are valid: `1.hour` and `24.hours`. + +Primitive types have no properties or methods. For domain-specific string types (email addresses, URLs), use value types or plain `String` fields with descriptive names. For operations on primitives beyond the built-in operators, use black box functions (e.g., `length(password)`, `hash(password)`). + +**Compound types:** +- `Set` — unordered collection of unique items +- `List` — ordered collection (use when order matters). A compound field type declared explicitly on entities +- `Sequence` — ordered collection produced by ordered relationships and their projections. `Sequence` is a subtype of `Set`: an ordered collection is assignable where an unordered one is expected, but not the reverse. `List` is a field type you declare explicitly; `Sequence` is the collection type the checker infers when a relationship is ordered. Both carry ordering semantics, but they occupy different positions in the grammar +- `T?` — optional (may be absent). Reserved for genuinely optional fields: a user's nickname, a note that may or may not exist. For fields whose presence depends on lifecycle state, use a `when` clause instead (see below). + +**Checking for absent values:** +``` +requires: request.reminded_at = null -- field is absent/unset +requires: request.reminded_at != null -- field has a value +``` + +`null` represents the absence of a value for optional fields. + +`field = null` and `field != null` are presence checks, not comparisons. `field = null` is true when the field is absent; `field != null` is true when the field has a value. Comparisons with null produce false: `null <= now` is false, `null > 0` is false. Arithmetic with null produces null: `null + 1.day` is null. This means temporal triggers on optional fields (e.g., `when: user: User.next_digest_at <= now`) do not fire when the field is absent. + +**State-dependent field presence (`when` clause):** + +A field declaration may carry a `when` clause tying its presence to lifecycle state: + +``` +entity Order { + status: pending | confirmed | shipped | delivered | cancelled + customer: Customer + total: Money + tracking_number: String when status = shipped | delivered + shipped_at: Timestamp when status = shipped | delivered + delivery_confirmed_at: Timestamp when status = delivered + + transitions status { + pending -> confirmed + confirmed -> shipped + shipped -> delivered + terminal: delivered, cancelled + } +} +``` + +Fields without `when` are present in all states. Fields with `when` are present only when the named status field holds one of the listed values. The `when` clause references a single status field; that field must have a `transitions` block. + +`?` and `when` are orthogonal. `reviewer_notes: String? when review = approved | rejected` means the field exists in those states but may be null within them. `?` is genuine optionality; `when` is lifecycle-dependent presence. A field may carry both. + +Entities with multiple status fields use the qualified form to disambiguate: + +``` +entity Document { + status: draft | published | archived + review: pending | approved | rejected + + transitions status { ... } + transitions review { ... } + + published_at: Timestamp when status = published | archived + reviewer_notes: String when review = rejected +} +``` + +Each `when` clause references one status field. Compound conditions across multiple status fields (`when status = published and review = rejected`) are not supported; use invariants for cross-field constraints. + +The `when` keyword appears in three syntactic positions: rule triggers (`when: TriggerCondition`, with colon), surface and provides guards (`Action(...) when condition`, without colon, after an action), and field declarations (`field: Type when status = value`, without colon, after a type). The grammar is unambiguous at each position. Rule triggers are identified by the colon. Surface guards follow an action or related clause. Field `when` clauses follow a type declaration. + +**Presence and absence obligations.** Obligations fire when a rule crosses the boundary of a field's `when` set: + +- **Entering** (source state outside `when` set, target state inside): the rule must set the field. +- **Leaving** (source state inside `when` set, target state outside): the rule must clear the field. +- **Moving within** (both states inside): no obligation. The field is already present and remains present; the rule may update it but need not. +- **Moving outside** (both states outside): no obligation. + +``` +entity Document { + status: active | deleted + deleted_at: Timestamp when status = deleted + deleted_by: User when status = deleted + + transitions status { + active -> deleted + deleted -> active + terminal: active + } +} + +rule SoftDelete { + when: SoftDelete(document, actor) + requires: document.status = active + ensures: + document.status = deleted + document.deleted_at = now -- entering when set: must set + document.deleted_by = actor -- entering when set: must set +} + +rule RestoreDocument { + when: RestoreDocument(document) + requires: document.status = deleted + ensures: + document.status = active + document.deleted_at = null -- leaving when set: must clear + document.deleted_by = null -- leaving when set: must clear +} +``` + +**Access without guard.** Accessing a `when`-qualified field without a `requires` guard narrowing to a qualifying state is an error: + +``` +-- Error: tracking_number requires status in {shipped, delivered} +rule BadAccess { + when: SomeEvent(order) + ensures: Label.created(tracking: order.tracking_number) +} + +-- Valid: requires narrows to a qualifying state +rule GenerateLabel { + when: GenerateLabel(order) + requires: order.status = shipped + ensures: Label.created(tracking: order.tracking_number) +} +``` + +**Convergent transitions.** If two rules reach the same state and the entity requires a field at that state, both must set it: + +``` +rule CancelByCustomer { + when: CustomerCancels(order) + requires: order.status = pending + ensures: + order.status = cancelled + order.cancelled_at = now -- required: entering when set + order.cancelled_by = customer -- required: entering when set +} + +rule CancelByTimeout { + when: order: Order.created_at + 48.hours <= now + requires: order.status = pending + ensures: + order.status = cancelled + order.cancelled_at = now -- required: entering when set + -- Error: cancelled_by not set +} +``` + +**Enumerated types (inline):** +``` +status: pending | confirmed | declined | expired +``` + +**Named enumerations:** +``` +enum Recommendation { strong_yes | yes | no | strong_no } +enum DayOfWeek { monday | tuesday | wednesday | thursday | friday | saturday | sunday } +``` + +Named enumerations define a reusable set of values. Declare them in the Enumerations section of the file. Reference them as field types: `recommendation: Recommendation`. Inline enums (`status: pending | active`) are equivalent but anonymous; use named enums when the same set of values appears in multiple fields or entities. + +**Backtick-quoted enum literals:** + +Enum values that reference external standards may contain characters outside the snake_case set (hyphens, dots, mixed case, leading digits). Enclose these in backticks: + +``` +enum InterfaceLanguage { en | de | fr | `de-CH-1996` | es | `zh-Hant-TW` | `sr-Latn` } +enum CacheDirective { `no-cache` | `no-store` | `must-revalidate` | `max-age` } +``` + +Backtick-quoted literals are values, not identifiers. They participate in equality comparison and assignment. The checker does not apply case convention rules inside backticks. Comparison is byte-exact after UTF-8 encoding; authors are responsible for using the canonical form from the external standard. Quoted and unquoted forms are distinct values with no implicit normalisation: `de_ch_1996` and `` `de-CH-1996` `` are different values. + +Backtick-quoted literals are permitted in enum declarations (named and inline), literal comparisons in rules and `ensures` clauses. They are not permitted in identifier positions (field names, entity names, variant names, config parameter names, derived value names, rule/trigger/invariant names). They cannot appear in arithmetic expressions. + +Inline enums are anonymous: they have no type identity. Two inline enum fields cannot be compared with each other, whether on the same entity or across entities; the checker reports an error. Use a named enum when values need to be compared across fields. Named enums are distinct types: a field typed `Recommendation` cannot be compared with a field typed `DayOfWeek`, even if they happen to share a literal. + +This catches a common mistake when tracking previous state: + +``` +-- Error: cannot compare two inline enum fields +entity Order { + status: pending | shipped | delivered + previous_status: pending | shipped | delivered +} +requires: order.status != order.previous_status -- checker error + +-- Fix: extract a named enum +enum OrderStatus { pending | shipped | delivered } +entity Order { + status: OrderStatus + previous_status: OrderStatus +} +requires: order.status != order.previous_status -- valid +``` + +### Transition graphs + +A transition graph declares the valid lifecycle transitions for an enum status field. When present, the graph is authoritative: rules whose `ensures` clauses produce transitions not in the graph are validation errors. Entities without a declared graph continue to derive transition validity from rules alone, with no change in checker behaviour. + +The graph lives inside the entity body, below the field it governs, introduced by the `transitions` keyword followed by the field name: + +``` +entity Order { + status: pending | confirmed | shipped | delivered | cancelled + + transitions status { + pending -> confirmed + confirmed -> shipped + shipped -> delivered + pending -> cancelled + confirmed -> cancelled + terminal: delivered, cancelled + } +} +``` + +**Edges.** Each line in the block is a directed edge using `->`: `from_state -> to_state`. The graph declares which transitions are possible (structural topology), not when or why they occur (conditional logic). Conditions remain in rules. The graph says "this edge exists"; the rule says "this edge fires under these conditions". A complete understanding of lifecycle behaviour requires reading both the graph and the rules. + +**Terminal states.** Terminal states are declared with a `terminal:` clause listing the terminal values. This is the sole mechanism for terminal marking. Absence of outbound edges does not imply terminal status; the checker requires explicit declaration. + +**Completeness obligations.** When a graph is declared, the checker enforces two obligations: +1. Every non-terminal state has at least one outbound edge in the graph. +2. Every declared edge is witnessed by at least one rule whose `requires`/`ensures` pair can produce that transition. + +The converse (every rule transition appears in the graph) is enforced by the authoritative relationship itself. The graph is identified by entity and field name (e.g. `Order.status`) in error messages; no separate name declaration is needed. + +**Enum reference, not redeclaration.** The graph references enum values already declared on the field. The checker enforces exact correspondence: every value in the graph must exist on the field, and every field value must appear in at least one edge or as a terminal. Drift is a hard error. + +**Opt-in.** The checker does not emit warnings or suggestions about missing graphs on entities that lack them. The construct earns adoption through demonstrated value, not through tooling pressure. + +**Multiple status fields.** Entities with multiple status fields use independent single-field graphs. Cross-field constraints are expressed through invariants. + +**Generality.** Transition graphs currently target enum status fields. The syntax is designed to extend to variant discriminators and other lifecycle-bearing fields in future versions without structural changes. + +**Interaction with state-dependent fields.** When a transition graph is declared, the checker uses its structure alongside `when` clauses on field declarations to enforce presence and absence obligations at each transition (see [Field types](#field-types)) and to verify that `when`-qualified fields are only accessed within qualifying state guards. + +**Entity references:** +``` +candidate: Candidate +role: Role +``` + +### Relationships + +Always use singular entity names; the relationship name indicates plurality: + +``` +-- One-to-one (singular relationship name) +invitation: Invitation with candidacy = this + +-- One-to-many (plural relationship name, but singular entity name) +slots: InterviewSlot with candidacy = this +feedback_requests: FeedbackRequest with interview = this + +-- Self-referential +replies: Comment with reply_to = this +``` + +The `with X = this` syntax declares a relationship by naming the field on the related entity that points back. `this` refers to the enclosing entity instance. The syntax is the same whether the relationship is one-to-one, one-to-many or self-referential. + +The relationship name determines the cardinality: + +- **Singular name** (e.g., `invitation`) — at most one related entity. The value is the entity instance, or `null` if none exists. Equivalent to `T?`. If multiple entities match a singular relationship, the specification is in error and the checker should report it. +- **Plural name** (e.g., `slots`) — zero or more related entities. The value is a collection, empty if none exist. + +Relationships currently produce `Set` (unordered). The declaration syntax for ordered relationships (which would produce `Sequence`) is pending a follow-up ALP. The semantic model for ordered collections is defined; see [Collection operations](#collection-operations) for the type-level rules. + +### Projections + +Named filtered views of relationships: + +``` +-- Simple status filter +confirmed_slots: slots where status = confirmed + +-- Multiple conditions +active_requests: feedback_requests where status = pending and requested_at > cutoff + +-- Projection with mapping +confirmed_interviewers: confirmations where status = confirmed -> interviewer +``` + +The `-> field` syntax extracts a field from each matching entity. When the extracted field is optional (`T?`), null values are excluded from the result: the projection produces `Set`, not `Set`. + +Projections preserve ordering. If the source collection is a `Sequence`, the projection result is also a `Sequence` in the same relative order. This applies to both `where` filtering and `-> field` extraction. Field extraction on ordered collections retains duplicates (two source elements navigating to the same target produce two entries in sequence order); use `.unique` to deduplicate, which produces an unordered `Set`. + +### Derived values + +Computed from other fields. Always read-only and automatically updated. + +``` +-- Boolean derivations +is_valid: interviewers.any(i => i.can_solo) or interviewers.count >= 2 +is_expired: expires_at <= now +all_responded: pending_requests.count = 0 + +-- Value derivations +time_remaining: deadline - now + +-- Parameterised derived values +can_use_feature(f): f in plan.features +has_permission(p): p in role.effective_permissions +``` + +Parameters are locally scoped to the expression. Parameterised derived values cannot reference module `given` bindings or global state; they operate only on the entity's own fields and their parameter. No side effects. + +--- + +## Rules + +Rules define behaviour: what happens when triggers occur. + +### Rule structure + +``` +rule RuleName { + when: TriggerCondition + + let binding1 = expression -- bindings can appear before requires + + requires: Precondition1 + requires: Precondition2 + + let binding2 = expression -- or between requires and ensures + + ensures: Postcondition1 + ensures: Postcondition2 + + @guidance -- optional, always last + -- Non-normative implementation advice. +} +``` + +| Clause | Purpose | +|--------|---------| +| `when` | What triggers this rule | +| `for` | Iterate: apply the rule body for each element in a collection | +| `let` | Local variable bindings (can appear anywhere after `when`) | +| `requires` | Preconditions that must be true (rule fails if not met) | +| `ensures` | What becomes true after the rule executes | +| `@guidance` | Non-normative implementation advice (optional, always last) | + +Place `let` bindings where they make the rule most readable, typically just before the clause that first uses them. + +### Derived value `when` propagation + +Derived values computed from `when`-qualified fields inherit the intersection of their inputs' `when` sets: + +``` +entity Order { + status: pending | confirmed | shipped | delivered + shipped_at: Timestamp when status = shipped | delivered + delivery_confirmed_at: Timestamp when status = delivered + + transitions status { + pending -> confirmed + confirmed -> shipped + shipped -> delivered + terminal: delivered + } + + -- Inferred: when status = delivered + -- (intersection of {shipped, delivered} and {delivered}) + days_in_transit: delivery_confirmed_at - shipped_at +} +``` + +The checker infers this; the author does not declare it. If the intersection is empty, the derived value is unreachable and the checker reports an error. + +An author may optionally annotate a derived value with an explicit `when` clause as documentation: + +``` +days_in_transit: delivery_confirmed_at - shipped_at when status = delivered +``` + +When present, the checker verifies it matches the inferred set. A mismatch is an error. When absent, the inferred set applies silently. The checker exports inferred `when` sets as structured data alongside state-level summaries. + +**Cross-entity access.** Accessing a `when`-qualified field on a related entity requires a guard narrowing the related entity's status to a qualifying state. `candidacy.order.tracking_number` requires that the rule's context narrows `order.status` to a qualifying state. + +### Rule-level iteration + +A `for` clause applies the rule body once per element in a collection. The binding variable is available in all subsequent clauses. + +``` +rule ProcessDigests { + when: schedule: DigestSchedule.next_run_at <= now + for user in Users where notification_setting.digest_enabled: + let settings = user.notification_setting + ensures: DigestBatch.created(user: user, ...) +} +``` + +The `where` keyword filters the collection, consistent with projection syntax. The indented body contains the rule's `let`, `requires` and `ensures` clauses scoped to each element. + +This is the same `for x in collection:` construct used in ensures blocks and surfaces. The body inherits the constraints of its enclosing context: at rule level it wraps `let`, `requires` and `ensures` clauses; inside an ensures block it wraps postconditions (state changes, entity creation, trigger emissions, removal assertions); inside a surface it wraps the items permitted by the enclosing clause (`exposes`, `provides` or `related`). + +### Multiple rules for the same trigger + +When multiple rules share a trigger, their `requires` clauses determine which fires. If preconditions overlap such that multiple rules could match simultaneously, this is a spec ambiguity. The specification checker should warn when rules with the same trigger have overlapping preconditions. + +### Trigger types + +**External stimulus** — action from outside the system: +``` +when: AdminApprovesInterviewers(admin, suggestion, interviewers, times) +when: CandidateSelectsSlot(invitation, slot) +``` + +**Optional parameters** use the `?` suffix: +``` +when: InterviewerReportsNoInterview(interviewer, interview, reason, details?) +``` + +**State transition** — entity changed state: +``` +when: interview: Interview.status transitions_to scheduled +when: confirmation: SlotConfirmation.status transitions_to confirmed +``` + +The variable before the colon binds the entity that triggered the transition. `transitions_to` fires when a field transitions to the specified value from a different value, not on initial entity creation (use `.created` for that). It is valid for enum fields, boolean fields and entity reference fields. When a transition graph is declared for the field, only transitions in the graph are structurally valid; rules producing other transitions are validation errors. + +**State becomes** — entity has a value, whether by creation or transition: +``` +when: interview: Interview.status becomes scheduled +``` + +`becomes` fires both when an entity is created with the specified value and when a field transitions to that value from a different value. Like `transitions_to`, it is valid for enum fields, boolean fields and entity reference fields. It is equivalent to writing a `transitions_to` rule and a `.created` rule with a `requires` guard, combined into a single trigger. Use `becomes` when the rule should apply regardless of how the entity arrived at the state. Use `transitions_to` when the rule should only apply to transitions (e.g., sending a "rescheduled" notification that doesn't apply on initial creation). + +**Temporal** — time-based condition: +``` +when: invitation: Invitation.expires_at <= now +when: interview: Interview.slot.time.start - 1.hour <= now +when: request: FeedbackRequest.requested_at + 24.hours <= now +``` + +Temporal triggers use explicit `var: Type` binding, the same as state transitions and entity creation. The binding names the entity instance and its type. Temporal triggers fire once when the condition becomes true. Always include a `requires` clause to prevent re-firing: +``` +rule InvitationExpires { + when: invitation: Invitation.expires_at <= now + requires: invitation.status = pending -- prevents re-firing + ensures: invitation.status = expired +} +``` + +**Derived condition becomes true:** +``` +when: interview: Interview.all_feedback_in +when: slot: InterviewSlot.is_valid +``` + +Derived condition triggers fire when the value transitions from false to true, the same semantics as temporal triggers. If the derived value could revert to false and become true again, include a `requires` clause to prevent re-firing, just as with temporal triggers. + +**Entity creation** — fires when a new entity is created: +``` +when: batch: DigestBatch.created +when: mention: CommentMention.created +``` + +**Chained from another rule's trigger emission:** +``` +when: AllConfirmationsResolved(candidacy) +``` + +A rule chains from another by subscribing to a trigger emission. The emitting rule includes the event in an ensures clause: + +``` +ensures: AllConfirmationsResolved(candidacy: candidacy) +``` + +The receiving rule subscribes via its `when` clause. This uses the same syntax as external stimulus triggers, but the stimulus comes from another rule rather than from outside the system. + +### Preconditions (requires) + +Preconditions must be true for the rule to execute. If not met, the trigger is rejected. + +``` +requires: invitation.status = pending +requires: not invitation.is_expired +requires: slot in invitation.slots +requires: interviewer in interview.interviewers +requires: + interviewers.count >= 2 + or interviewers.any(i => i.can_solo) +``` + +**Precondition failure behaviour:** +- For external stimulus triggers: The action is rejected; caller receives an error +- For temporal/derived triggers: The rule simply does not fire; no error +- For chained triggers: The chain stops; previous rules' effects still apply + +### Local bindings (let) + +``` +let confirmation = SlotConfirmation{slot, interviewer} +let time_until = interview.slot.time.start - now +let is_urgent = time_until < 24.hours +let is_modified = + interviewers != suggestion.suggested_interviewers + or proposed_times != suggestion.suggested_times +``` + +### Discard bindings + +Use `_` where a binding is required syntactically but the value is not needed. Multiple `_` bindings in the same scope do not conflict. + +``` +when: _: LogProcessor.last_flush_check + flush_timeout_hours <= now +when: SomeEvent(_, slot) +for _ in items: Counted(batch) +``` + +### Postconditions (ensures) + +Postconditions describe what becomes true. They are declarative assertions about the resulting state, not imperative commands. + +In state change assignments (`entity.field = expression`), the expression on the right references pre-rule field values. This avoids circular definitions: `user.count = user.count + 1` means the resulting count equals the original count plus one. Conditions within ensures blocks (`if` guards, creation parameters, trigger emission parameters) reference the resulting state as defined by the state changes. A `let` binding within an ensures block introduces a name visible to all subsequent statements in that block. + +Worked example: suppose `account.balance` is 100 before the rule fires. + +``` +ensures: account.balance = account.balance + 50 -- RHS reads pre-rule value: 100 + 50 = 150 +ensures: + if account.balance > 120: -- condition reads resulting state: 150 > 120, true + Notification.created(account: account, type: high_balance) +``` + +The assignment reads 100 (the pre-rule value). The `if` guard reads 150 (the resulting state after the assignment). + +Common mistake: assuming `if` guards in ensures read pre-rule values. Suppose `order.status` is `pending` before the rule fires. + +``` +ensures: order.status = shipped +ensures: + if order.status = pending: -- WRONG: reads resulting state (shipped), so this is false + Notification.created(to: order.customer, template: order_pending_reminder) + if order.status = shipped: -- reads resulting state (shipped), so this is true + Notification.created(to: order.customer, template: order_shipped) +``` + +The author likely meant "if the order was pending before we changed it". But the `if` guard inside ensures reads the resulting state, not the pre-rule state. To test pre-rule values, use a `let` binding or `requires` clause before the ensures block. + +Ensures clauses have four forms: + +**State changes** — modify an existing entity's fields: +``` +ensures: slot.status = booked +ensures: invitation.status = accepted +ensures: candidacy.retry_count = candidacy.retry_count + 1 +ensures: user.locked_until = null -- clearing an optional field +``` + +Setting an optional field to `null` asserts the field becomes absent. Only valid for fields typed as optional (`T?`). + +**Entity creation** — create a new entity using `.created()`: +``` +ensures: Interview.created( + candidacy: invitation.candidacy, + slot: slot, + interviewers: slot.confirmed_interviewers, + status: scheduled +) + +ensures: Email.created( + to: candidate.email, + template: interview_invitation, + data: { slots: slots } +) + +ensures: CalendarInvite.created( + attendees: interviewers + candidate, + time: slot.time, + duration: interview_type.duration +) +``` + +Entity creation uses `.created()` exclusively. Domain meaning lives in entity names and rule names, not in creation verbs. `Email.created(...)` not `Email.sent(...)`. + +When creating entities that need to be referenced later in the same ensures block, use explicit `let` binding: +``` +ensures: + let slot = InterviewSlot.created(time: time, candidacy: candidacy, status: pending) + for interviewer in interviewers: + SlotConfirmation.created(slot: slot, interviewer: interviewer) +``` + +A `let` binding within an ensures block is visible to all subsequent statements in that block, including nested `for` loops. It does not leak outside the ensures block. + +**Trigger emission** — emit a named event that other rules can chain from: +``` +ensures: CandidateInformed( + candidate: candidacy.candidate, + about: slot_unavailable, + data: { available_alternatives: remaining_slots } +) + +ensures: UserMentioned(user: mention.user, comment: comment, mentioned_by: author) +ensures: FeatureUsed(workspace: workspace, feature: feature, by: user) +``` + +Trigger emissions are observable outcomes, not entity creation. They have no `.created()` call and are referenced by other rules' `when` clauses. Parameter values follow normal expression resolution: bound names are resolved first, then enum literals if the parameter has a declared type on the receiving rule. Bare identifiers that resolve to neither a binding nor an enum literal are a checker warning. + +**Entity removal:** +``` +ensures: not exists target_membership +ensures: not exists CommentMention{comment, user} +``` + +See [Existence](#existence) in the expression language for the full syntax including bulk removal and the distinction from soft delete. + +**Bulk updates:** +``` +ensures: + for s in invitation.proposed_slots: + s.status = cancelled +``` + +**Conditional outcomes:** +``` +ensures: + if candidacy.retry_count < 2: + candidacy.status = pending_scheduling + else: + candidacy.status = scheduling_stalled + Notification.created(...) +``` + +--- + +## Expression language + +### Navigation + +``` +-- Field access +interview.status +candidate.email + +-- Relationship traversal +interview.feedback_requests +candidacy.slots + +-- Chained navigation +interview.candidacy.candidate.email +feedback_request.interview.slot.time + +-- Optional navigation (short-circuits to null if left side is null) +inherits_from?.effective_permissions +reply_to?.author + +-- Null coalescing (provides default when left side is null) +identity.timezone ?? "UTC" +inherits_from?.effective_permissions ?? {} + +-- State-dependent fields: ?. is not needed for when-qualified fields +-- when the requires clause narrows to a qualifying state +order.tracking_number -- valid when requires: order.status = shipped + +-- Self-reference +this -- the instance being defined or identified +replies: Comment with reply_to = this -- all Comments whose reply_to is this entity +``` + +`this` refers to the instance of the enclosing type. It is valid in two contexts: + +- **Entity declarations**: `this` is the current entity instance. Available in relationships, projections and derived values. +- **Actor `identified_by` expressions**: `this` is the entity instance being tested for actor membership (see [Actor declarations](#actor-declarations)). + +### Join lookups + +For entities that connect two other entities (join tables): + +``` +let confirmation = SlotConfirmation{slot, interviewer} +let feedback_request = FeedbackRequest{interview, interviewer} +``` + +Curly braces with field names look up the specific instance where those fields match. Any number of fields can be specified. Each name serves as both the field name on the entity and the local variable whose value is matched. The lookup must match at most one entity; if the fields do not uniquely identify a single instance, the specification is ambiguous and the checker should report an error. If no entity matches, the binding is null. Use `exists` to test whether a lookup matched before accessing fields on it; accessing fields on a null binding is an error. + +When the local variable name differs from the field name, use the explicit form: + +``` +let actor_membership = WorkspaceMembership{user: actor, workspace: workspace} +let share = ResourceShare{resource: resource, user: inviter} +requires: not exists User{email: new_email} +``` + +### Collection operations + +``` +-- Count +slots.count +pending_requests.count + +-- Membership +slot in invitation.slots +interviewer in interview.interviewers + +-- Any/All (always use explicit lambda) +interviewers.any(i => i.can_solo) +confirmations.all(c => c.status = confirmed) + +-- Filtering (in projections and expressions) +slots where status = confirmed +requests where status in {submitted, escalated} + +-- Iteration (introduces a scope block) +for slot in slots: ... + +-- Set mutation (ensures-only, modifies a relationship) +interviewers.add(new_interviewer) +interviewers.remove(leaving_interviewer) + +-- Set arithmetic (expression-level, produces a new set) +all_permissions: permissions + inherited_permissions +removed_mentions: old_mentions - new_mentions + +-- First/last (ordered collections only: Sequence or List) +attempts.first +attempts.last + +-- Deduplicate (produces unordered Set) +ordered_interviewers.unique +``` + +`.first` and `.last` are restricted to ordered collections (`Sequence` or `List`). Using them on a `Set` is a warning in the current version, becoming a hard error in the next version. + +`.unique` deduplicates a collection. Because deduplication discards positional information, the result is always an unordered `Set`, even when the source is ordered. + +`for item in collection:` iterates in declared order when the source is a `Sequence` or `List`. When the source is a `Set`, iteration order is unspecified. + +`.add()` and `.remove()` are ensures-only mutations on a relationship. Set `+` and `-` are expression-level operations that produce new sets without mutating anything. When applied to ordered collections (`Sequence` or `List`), `+` and `-` produce unordered results (`Set`). The checker reports the type change if the result is used where ordering is expected. + +Dot-method syntax on collections is reserved for built-in operations. The built-in dot-methods are: `.count`, `.any()`, `.all()`, `.first`, `.last`, `.unique`, `.add()`, `.remove()`. The checker rejects any dot-method call on a collection whose name is not in this set. Domain-specific collection operations use free-standing black box function syntax with the collection as the first argument (see [Black box functions](#black-box-functions)). + +### Comparisons + +``` +status = pending +status != proposed +count >= 2 +expires_at <= now +time_until < 24.hours +status in {confirmed, declined, expired} +provider not in user.linked_providers +``` + +`{value1, value2, ...}` is a set literal used with `in` and `not in` for membership tests. This is the same set literal syntax used in field declarations and expressions. + +### Arithmetic + +``` +candidacy.retry_count + 1 +interview.slot.time.start - now +feedback_request.requested_at + 24.hours +now + 7.days +recent_failures.count / config.window_sample_size +price * quantity +``` + +Four operators: `+`, `-`, `*`, `/`. Standard precedence: `*` and `/` bind tighter than `+` and `-`. Use parentheses to override. Arithmetic involving null produces null (e.g., `null + 1.day` is null). Derived values computed from optional fields are implicitly optional. + +### Boolean logic + +``` +interviewers.count >= 2 or interviewers.any(i => i.can_solo) +invitation.status = pending and not invitation.is_expired +not (a or b) -- equivalent to: not a and not b +``` + +`and` and `or` short-circuit left to right. If the left operand of `or` is true, the right operand is not evaluated; if the left operand of `and` is false, the right operand is not evaluated. This permits patterns like `not exists x or not x.is_valid`, where the right side is only reached when `x` exists. + +### Implication + +``` +account.status = closed implies account.balance = 0 +not user.is_verified implies user.permissions.count = 0 +``` + +`implies` has the lowest precedence of any boolean operator, binding looser than `and` and `or`. `a implies b` is equivalent to `not a or b`. Available in all expression contexts. Its primary use case is invariant assertions, but it reads naturally in `requires` guards and derived boolean values as well. + +### Conditional expressions + +``` +-- Inline (single values) +email_status: if settings.email_on_mention = never: skipped else: pending +thread_depth: if is_reply: reply_to.thread_depth + 1 else: 0 + +-- Block (multiple outcomes) +ensures: + if candidacy.retry_count < 2: + candidacy.status = pending_scheduling + else: + candidacy.status = scheduling_stalled + Notification.created(...) +``` + +Both forms use the same `if condition: ... else: ...` syntax. The inline form is for single-value assignments only. If either branch needs multiple statements or entity creation, use block form. Omit `else` when only the true branch has an effect. + +Multi-branch conditionals use `else if`: + +``` +let preference = + if notification.kind = MentionNotification: settings.email_on_mention + else if notification.kind = ReplyNotification: settings.email_on_comment + else if notification.kind = ShareNotification: settings.email_on_share + else: immediately +``` + +Each `else if` adds a branch. The final `else` provides a fallback. + +`exists` can also be used as a condition in `if` expressions, not just in `requires`. When `exists x` is used as an `if` condition, `x` is guaranteed non-null within the `if` body and can be accessed safely: + +``` +ensures: + if exists existing: + not exists existing + else: + CommentReaction.created(comment: comment, user: user, emoji: emoji) +``` + +### Existence + +The `exists` keyword checks whether an entity instance exists. Use `not exists` for negation. + +``` +-- Entity looked up via let binding +let user = User{email} +requires: exists user + +-- Join entity lookup +requires: exists WorkspaceMembership{user, workspace} + +-- Negation +requires: not exists User{email: email} +requires: not exists ResourceInvitation{resource, email} +``` + +In `ensures` clauses, `not exists` asserts that an entity has been removed from the system: + +``` +-- Entity removal +ensures: not exists target_membership +ensures: not exists CommentMention{comment, user} + +-- Bulk removal +ensures: + for d in workspace.deleted_documents: + not exists d +``` + +If the entity is already absent, the postcondition is trivially satisfied (no error, no operation). This follows from declarative semantics: `not exists x` asserts a property of the resulting state, not an imperative command. + +This is distinct from soft delete, which changes a field rather than removing the entity: + +``` +-- Soft delete (entity still exists, status changes) +ensures: document.status = deleted + +-- Hard delete (entity no longer exists) +ensures: not exists document +``` + +### Literals + +``` +-- Set literals +permissions: { "documents.read", "documents.write" } +features: { basic_editing, api_access } + +-- Object literals (anonymous records, used in creation parameters and trigger emissions) +data: { candidate: candidate, time: time } +data: { slots: remaining_slots } +data: { unlocks_at: user.locked_until } +``` + +Object literals are anonymous record types. They carry named fields but have no declared type. Use them for ad-hoc data in entity creation parameters and trigger emission payloads where defining a named type would add ceremony without clarity. Object literals always require explicit `key: value` pairs; `{ x }` is a set literal containing `x`, not an object with shorthand. + +### Black box functions + +Black box functions represent domain logic too complex or algorithmic for the spec level. They appear in expressions and their behaviour is described by comments or deferred specifications. Black box functions always use free-standing call syntax; they never use dot-method syntax. + +``` +-- Scalar black box functions +hash(password) -- black box +verify(password, user.password_hash) -- black box +parse_mentions(body) -- black box: extracts @username +next_digest_time(user) -- black box: uses digest_day_of_week + +-- Collection-operating black box functions (collection as first argument) +filter(events, e => e.recent) -- black box +grouped_by(copies, r => r.output_payloads) -- black box +min_by(pending, e => e.offset) -- black box +flatMap(groups, g => g.deferred_events) -- black box +``` + +Black box functions are pure (no side effects) and deterministic for the same inputs within a rule execution. + +The distinction between built-in operations and black box functions is syntactic: dot-method calls on collections (`.count`, `.any()`, `.all()`, `.first`, `.last`, `.unique`, `.add()`, `.remove()`) are built-in with language-defined semantics. Free-standing function calls are black box with implementation-defined semantics. The checker enforces this boundary: an unrecognised dot-method on a collection is an error. Built-in operations may chain from the result of a black box function call, since the result is a collection: `filter(events, e => e.recent).count` is valid. + +### The `with` and `where` keywords + +`with` declares how entities are connected. `where` selects from those connections. + +A relationship declaration says "these are the InterviewSlots that belong to this Candidacy". A projection says "of those slots, show me the confirmed ones": + +``` +-- Relationship: declares which InterviewSlots belong to this Candidacy +slots: InterviewSlot with candidacy = this + +-- Projection: of those slots, keep the confirmed ones +confirmed_slots: slots where status = confirmed +``` + +Because `with` defines a relationship from the universe of all instances, it needs `this` as an anchor — the predicate must reference the enclosing entity to establish the link. Because `where` filters an already-scoped collection, `this` would be meaningless and must not appear. + +- **`with`** appears in relationship declarations. The predicate defines the structural link and must reference `this`. +- **`where`** appears in projections, iteration, surface context, actor identification and surface `let` bindings. The predicate filters an existing collection and must not reference `this`. + +``` +-- Surface context (where) +context assignment: SlotConfirmation where interviewer = viewer + +-- Actor identification (where) +User where role = admin + +-- Iteration (where) +for user in Users where notification_setting.digest_enabled: + +-- Surface let binding (where) +let comments = Comments where parent = parent and status = active +``` + +Both `with` and `where` predicates support the same expression language as `requires` clauses: field navigation (including chained), comparisons, arithmetic, boolean combinators (`and`, `or`, `not`), bare boolean expressions and `in` for set membership. `where notification_setting.digest_enabled` and `where notification_setting.digest_enabled = true` are equivalent. + +### Entity collections + +The pluralised type name refers to all instances of that entity: + +``` +for user in Users where notification_setting.digest_enabled: + ... +``` + +`Users` means all instances of `User`. Use natural English plurals: `Users`, `Documents`, `Workspaces`, `Candidacies`. + +Entity collections are typically used in rule-level `for` clauses and surface `let` bindings to iterate or filter across all instances of a type. + +--- + +## Invariants + +Invariants are named assertions about properties that must hold over entity state. They appear at two scopes: top-level (system-wide properties) and entity-level (properties of individual instances). + +### Top-level invariants + +Top-level invariants assert properties over entity collections. They appear in the Invariants section after Rules: + +``` +invariant NonNegativeBalance { + for account in Accounts: + account.balance >= 0 +} + +invariant NoOverlappingInterviews { + for a in Interviews: + for b in Interviews: + a != b and a.candidate = b.candidate + implies not (a.start < b.end and b.start < a.end) +} + +invariant UniqueEmail { + for a in Users: + for b in Users: + a != b implies a.email != b.email +} +``` + +Each invariant has a PascalCase name and a brace-delimited body containing an expression that evaluates to a boolean. + +### Entity-level invariants + +Entity-level invariants assert properties scoped to a single entity type. They appear inside entity declarations alongside fields, relationships and derived values: + +``` +entity Account { + balance: Decimal + credit_limit: Decimal + status: active | frozen | closed + + invariant SufficientFunds { + balance >= -credit_limit + } + + invariant FrozenAccountsCannotTransact { + status = frozen implies pending_transactions.count = 0 + } +} +``` + +Within an entity-level invariant, field names resolve to the enclosing entity's fields without qualification. `this` refers to the entity instance. + +### Expression language + +Invariant expressions use the existing expression language without extension: + +| Construct | Example | +|-----------|---------| +| Navigation | `account.balance`, `slot.interview.candidate` | +| Optional navigation | `parent?.status` | +| Comparisons | `balance >= 0`, `status = active`, `status in {active, pending}` | +| Boolean logic | `a and b`, `a or b`, `not a`, `a implies b` | +| Arithmetic | `balance + pending`, `count * rate` | +| Collection operations | `slots.count`, `slots.any(s => s.status = confirmed)` | +| Quantification | `for x in Collection: expression` (universal, all elements must satisfy) | +| Existence | `exists entity`, `not exists entity` | +| Null coalescing | `field ?? default` | +| Let bindings | `let total = debit + credit` (must be pure) | + +`for x in Collection:` in an invariant body is a universal quantifier: the invariant holds when the expression is true for every element. This reuses the existing `for` iteration syntax with assertion semantics rather than ensures semantics. Nested quantification is permitted. + +### Purity constraints + +Invariant expressions must be pure: + +- **No side effects.** Invariants cannot use `.add()`, `.remove()`, `.created()` or trigger emissions. +- **No `now`.** `now` is prohibited because it is volatile (re-evaluates on each read). Stored timestamp fields like `created_at` are permitted because they are stored state. The distinction is volatility, not temporality. +- **`let` bindings** are permitted but must themselves be pure expressions, subject to the same restrictions. + +### Checking semantics + +Invariants are logical assertions over entity state, not runtime checks. Checking frequency and strategy are tooling concerns: PBT checks invariants after rule sequences, the model checker checks exhaustively, the trace validator checks against reconstructed state. + +Invariant expressions that reference `when`-qualified fields must scope their quantification to qualifying states. `for o in Orders: o.tracking_number != null` is an error if `tracking_number` carries a `when` clause, because the field does not exist in all states. Use a guard: `for o in Orders: o.status in {shipped, delivered} implies o.tracking_number != null`. + +### Prose-only vs expression-bearing invariants + +Two syntactically distinct forms exist: + +- `@invariant Name` (sigil prefix, followed by indented comments) — prose annotation, used in contracts +- `invariant Name { expression }` (no sigil, braces) — expression-bearing, at top-level and entity-level scopes + +The prose annotation describes a property informally. The expression-bearing form is a machine-readable assertion that tooling can exercise. When a prose annotation is promoted to the expression-bearing form, the `@` is dropped and a `{ expr }` body is added in its place. + +### Recognising expressible invariants + +Expression-bearing invariants assert properties over entity state at a single point in time. They answer the question "given the current state of all entities, does this property hold?" Not all important properties have this shape. + +**Expressible** (use `invariant Name { expr }`): + +- Uniqueness across entity instances: "no two instances share a priority" +- Relationships between fields on the same entity: "save_block = must_save implies expected_save_version != null" +- Bounds on field values: "gap >= 1", "version >= 1" +- Structural relationships between collections: "L1 and L2 never hold the same key", "distinct causal groups have disjoint entity key sets" +- Subset and partition relationships: "processed events are a subset of group events", "processed and deferred together cover all events" + +The common thread: the property can be checked by reading current field values and navigating current relationships. No knowledge of history, ordering or external state is required. + +**Not expressible** (use prose comments or `@invariant` in contracts): + +- Cross-instance agreement: "all instances produce byte-identical outputs for the same input." This compares the behaviour of independent processes, not entity state. +- Temporal ordering: "event 2 sees the entity state left by event 1." This is about the order in which rules executed, not a static property. +- Evaluation function contracts: "the function is pure and deterministic." This constrains code behaviour, not entity state. Use `@invariant` inside a `contract` declaration. +- Counterfactual properties: "if a crash occurred, recovery could reconstruct this state." This reasons about a hypothetical scenario, not the current state. +- Monotonicity: "the watermark never decreases." This compares current state to prior state. A single-point-in-time invariant can assert a lower bound (`watermark >= -1`) but not that the value has not decreased since last observed. + +When in doubt, try writing the expression. If it requires comparing two moments in time, reasoning about what another process would do, or referencing the order in which rules fired, it belongs in prose. + +--- + +## Deferred specifications + +Reference detailed specifications defined elsewhere: + +``` +deferred InterviewerMatching.suggest -- see: detailed/interviewer-matching.allium +deferred SlotRecovery.initiate -- see: slot-recovery.allium +``` + +This allows the main specification to remain succinct while acknowledging that detail exists elsewhere. + +Deferred specifications are invoked at call sites using dot notation. They can appear as standalone ensures clauses or as expressions that return a value: + +``` +-- Standalone invocation (the deferred spec handles the outcome) +ensures: InterviewerMatching.suggest(candidacy) + +-- Expression usage (the deferred spec returns a value) +ensures: OnCallPaged(team: EscalationPolicy.at_level(level), priority: immediate) +``` + +Unlike black box functions, which model opaque external computations, deferred specifications represent Allium logic that is fully specified elsewhere. The deferred declaration signals that the detail exists and is maintained separately. + +--- + +## Open questions + +Capture unresolved design decisions: + +``` +open question "Admin ownership - should admins be assigned to specific roles?" +open question "Multiple interview types - how is type assigned to candidacy?" +``` + +Open questions are surfaced by the specification checker as warnings, indicating the spec is incomplete. + +--- + +## Config + +A `config` block declares configurable parameters for the specification. Each parameter has a name, type and default value. + +``` +config { + min_password_length: Integer = 12 + max_login_attempts: Integer = 5 + lockout_duration: Duration = 15.minutes + reset_token_expiry: Duration = 1.hour +} +``` + +Rules reference config values with dot notation: + +``` +requires: length(password) >= config.min_password_length +ensures: token.expires_at = now + config.reset_token_expiry +``` + +External specs declare their own config blocks. Consuming specs configure them via the qualified name: + +``` +oauth/config { + session_duration: 8.hours + link_expiry: 15.minutes +} +``` + +External config values are referenced as `oauth/config.session_duration`. + +### Config parameter references + +A config parameter's default value can reference a parameter from an imported module's config block using a qualified name: + +``` +use "./core.allium" as core + +config { + instance_id: String -- mandatory, no default + required_copies: Integer = core/config.required_copies -- defaults to core's value + publish_delay: Duration = core/config.publish_delay -- defaults to core's value +} +``` + +The local parameter can still be overridden by any consuming module. Resolution order: + +1. If the consuming module sets the parameter explicitly, that value wins. +2. Otherwise, the qualified reference is followed. If the referenced parameter was itself overridden, the overridden value is used. +3. Otherwise, the referenced parameter's own default value is used. + +Chains of references resolve transitively: if A defaults to B and B defaults to C, A resolves to C's value. The checker warns on chains longer than two levels of indirection. + +The config reference graph must be acyclic. The checker reports an error if resolving a config default would revisit a parameter already in the resolution chain. When two modules both override the same parameter in a shared dependency (diamond dependency), the checker reports a conflict rather than silently picking one. + +Renaming is permitted. The local parameter name need not match the referenced parameter's name, allowing domain-appropriate vocabulary. + +### Expression-form config defaults + +Config parameter defaults can be expressions combining qualified references, local config references and literal values with arithmetic operators: + +``` +use "./core.allium" as core + +config { + base_timeout: Duration = core/config.base_timeout + extended_timeout: Duration = core/config.base_timeout * 2 + buffer_size: Integer = core/config.batch_size + 10 + retry_limit: Integer = max_attempts - 1 -- local reference +} +``` + +Operators: `+`, `-`, `*`, `/` with standard precedence. Parenthesised sub-expressions are permitted for explicit precedence (`(base + 1) * 2`). Both local and qualified references are valid in expressions. The acyclicity rule applies uniformly to both cross-module and local reference edges. Expression-form defaults are evaluated once at config resolution time, after all overrides have been applied. They are not re-evaluated dynamically. + +Type compatibility table for config default expressions: + +| Left | Operator | Right | Result | +|------|----------|-------|--------| +| Integer | `+` `-` `*` `/` | Integer | Integer | +| Duration | `+` `-` | Duration | Duration | +| Duration | `*` `/` | Integer | Duration | +| Integer | `*` | Duration | Duration | +| Decimal | `+` `-` `*` `/` | Decimal | Decimal | +| Decimal | `*` `/` | Integer | Decimal | +| Integer | `*` | Decimal | Decimal | + +Integer division uses truncation toward zero. All other type combinations are type errors. `Duration * Decimal` and `Decimal * Duration` are type errors; duration scaling uses Integer multipliers only. Commutative rows are listed for scalar multiplication only; addition and subtraction require matching types (Integer with Integer, Duration with Duration, Decimal with Decimal). Config default expressions are restricted to arithmetic operators and config references; boolean expressions are not permitted. + +For default entity instances (seed data, base configurations), use `default` declarations. + +--- + +## Defaults + +Default declarations create named entity instances that exist unconditionally. They are available to all rules and surfaces without requiring creation by any rule. + +``` +default InterviewType all_in_one = { name: "All in one", duration: 75.minutes } + +default Role viewer = { + name: "viewer", + permissions: { "documents.read" } +} + +default Role editor = { + name: "editor", + permissions: { "documents.write" }, + inherits_from: viewer +} +``` + +--- + +## Modular specifications + +### Namespaces + +Namespaces are prefixes that organise names. Use qualified names to reference entities and triggers from other specs: + +``` +entity Candidacy { + candidate: Candidate + authenticated_via: google-oauth/Session +} +``` + +### Using other specs + +The `use` keyword brings in another spec with an alias: + +``` +use "github.com/allium-specs/google-oauth/abc123def" as oauth +use "github.com/allium-specs/feedback-collection/def456" as feedback + +entity Candidacy { + authenticated_via: oauth/Session + ... +} +``` + +Coordinates are immutable references (git SHAs or content hashes), not version numbers. No version resolution algorithms, no lock files. A spec is immutable once published. + +### Referencing external entities and triggers + +External specs' entities are used directly with qualified names: + +``` +rule RequestFeedback { + when: interview: Interview.slot.time.start + 5.minutes <= now + ensures: feedback/Request.created( + subject: interview, + respondents: interview.interviewers, + deadline: 24.hours + ) +} +``` + +### Responding to external triggers + +Any trigger or state transition from another spec can be responded to. No extension points need to be declared: + +``` +rule AuditLogin { + when: oauth/SessionCreated(session) + ensures: AuditLog.created(event: login, user: session.user) +} + +rule NotifyOnFeedbackSubmitted { + when: feedback/Request.status transitions_to submitted + ensures: + for admin in Users where role = admin: + Notification.created(to: admin, template: feedback_received) +} +``` + +### Configuration + +Imported specs expose their own config parameters. Consuming specs set values via the qualified name: + +``` +use "github.com/allium-specs/google-oauth/abc123def" as oauth + +oauth/config { + session_duration: 8.hours + link_expiry: 15.minutes +} +``` + +Reference external config values as `oauth/config.session_duration`. This uses the same `config` mechanism as local config blocks (see [Config](#config)). + +### Breaking changes + +Avoid breaking changes: accrete (add new fields, triggers, states; never remove or rename). If a breaking change is necessary, publish under a new name rather than a new version. Consumers update at their own pace; old coordinates remain valid forever. + +### Local specs + +For specs within the same project, use relative paths: + +``` +use "./candidacy.allium" as candidacy +use "./scheduling.allium" as scheduling +``` + +External entities in one spec may be internal entities in another. The boundary is determined by the `external` keyword, not by file location. + +--- + +## Surfaces + +A surface defines a contract at a boundary. A boundary exists wherever two parties interact: a user and an application, a framework and its domain modules, a service and its consumers. Each surface names the boundary and specifies what each party exposes and provides, with a `contracts:` clause for programmatic integration obligations. + +Surfaces serve two purposes: +- **Documentation**: Capture expectations about what each party sees, must contribute and can use +- **Test generation**: Generate tests that verify the implementation honours the contract + +Surfaces do not specify implementation details (database schemas, wire protocols, thread models, UI layout). They specify the behavioural contract both sides must honour. + +### Actor declarations + +When a surface has a specific external party, declare actor types: + +``` +actor Interviewer { + identified_by: User where role = interviewer +} + +actor Admin { + identified_by: User where role = admin +} + +actor AuthenticatedUser { + identified_by: User where active_sessions.count > 0 +} +``` + +The `identified_by` expression specifies the entity type and condition that identifies the actor. It takes the form `EntityType where condition`, where the condition uses the entity's own fields, derived values and relationships. When an actor type is used in a `facing` clause, the binding variable has the entity type from the actor's `identified_by` expression. For example, `facing viewer: Interviewer` where `Interviewer` has `identified_by: User where role = interviewer` binds `viewer` as type `User`. + +When an actor's identity depends on a context that varies per surface, declare the expected context type with a `within` clause and reference it in `identified_by`: + +``` +actor WorkspaceAdmin { + within: Workspace + identified_by: User where WorkspaceMembership{user: this, workspace: within}.can_admin = true +} +``` + +The `within` clause declares the entity type this actor requires from the surface's `context` binding. This makes the dependency explicit: the checker can verify that any surface using this actor provides a compatible context. + +Two keywords are available inside `identified_by`: + +- `this` — the entity instance being tested (here, the User). Same semantics as `this` in entity declarations. +- `within` — the entity bound by the `context` clause of the surface that uses this actor, constrained to the type declared in the actor's `within` clause. + +``` +surface WorkspaceManagement { + facing admin: WorkspaceAdmin + context workspace: Workspace -- matches WorkspaceAdmin's within: Workspace + ... +} +``` + +An actor declaration with a `within` clause can only be used in surfaces that declare a `context` clause. The surface's context type must match the actor's declared `within` type. + +The `facing` clause accepts either an actor type or an entity type directly. Use actor declarations when the boundary has specific identity requirements (e.g., `WorkspaceAdmin` requires admin membership). Use entity types directly when any instance of that entity can interact (e.g., `facing visitor: User` for a public-facing surface). For integration surfaces where the external party is code rather than a person, declare an actor type with a minimal `identified_by` expression rather than leaving the type undeclared. + +### Surface structure + +``` +surface SurfaceName { + facing party: ActorType + context item: EntityType [where predicate] + let binding = expression + + exposes: + item.field [when condition] + ... + + provides: + Action(party, item, ...) [when condition] + ... + + contracts: + demands ContractName -- counterpart must implement + fulfils ContractName -- this surface supplies + + @guarantee ConstraintName + -- Constraint description. + + @guidance + -- Non-normative advice. + + related: + OtherSurface(item.relationship) [when condition] + ... + + timeout: + RuleName [when temporal_condition] +} +``` + +Variable names (`party`, `item`) are user-chosen, not reserved keywords. All clauses are optional. + +| Clause | Purpose | +|--------|---------| +| `facing` | Who is on the other side of the boundary | +| `context` | What entity or scope this surface applies to (one surface instance per matching entity; absent when no entity matches) | +| `let` | Local bindings, same as in rules | +| `exposes` | Visible data (supports `for` iteration over collections) | +| `provides` | Available operations with optional when-guards (parameters are per-action inputs from the party) | +| `contracts` | References to module-level `contract` declarations with direction markers. `demands ContractName` indicates the counterpart must implement; `fulfils ContractName` indicates this surface supplies | +| `@guarantee` | Named constraint that must hold across the boundary (prose annotation; PascalCase name required) | +| `@guidance` | Non-normative implementation advice (prose annotation; no name; must appear last) | +| `related` | Associated surfaces reachable from this one; the parenthesised expression evaluates to the entity instance that the target surface's `context` clause binds to, and its type must match the target surface's context type | +| `timeout` | References to temporal rules that apply within this surface's context (the rule name must correspond to a defined rule with a temporal trigger) | + +### Examples + +``` +surface InterviewerPendingAssignments { + facing viewer: Interviewer + + context assignment: InterviewAssignment + where interviewer = viewer and status = pending + + exposes: + assignment.interview.scheduled_time + assignment.interview.candidate.name + assignment.interview.duration + + provides: + InterviewerConfirmsAssignment(viewer, assignment) + InterviewerDeclinesAssignment(viewer, assignment, reason?) +} +``` + +``` +surface InterviewerDashboard { + facing viewer: Interviewer + + context assignment: SlotConfirmation where interviewer = viewer + + exposes: + assignment.slot.time + assignment.slot.candidacy.candidate.name + assignment.status + assignment.slot.other_confirmations.interviewer.name + + provides: + InterviewerConfirmsSlot(viewer, assignment.slot) + when assignment.status = pending + InterviewerDeclinesSlot(viewer, assignment.slot) + when assignment.status = pending + + related: + InterviewDetail(assignment.slot.interview) + when assignment.slot.interview != null +} +``` + +**Contract reference example** — contracts are declared at module level and referenced in surfaces via a `contracts:` clause with `demands`/`fulfils` direction markers. + +``` +contract DeterministicEvaluation { + evaluate: (event_name: String, payload: ByteArray, current_state: ByteArray) -> EventOutcome + + @invariant Determinism + -- For identical inputs, evaluate must produce + -- byte-identical outputs across all instances. + + @invariant Purity + -- No I/O, no clock, no mutable state outside arguments. +} + +contract EventSubmitter { + submit: (idempotency_key: String, event_name: String, payload: ByteArray) -> EventSubmission + + @invariant AtMostOnceProcessing + -- Within the TTL window, duplicate submissions + -- receive the cached response. +} + +surface DomainIntegration { + exposes: + EntityKey + EventOutcome + + contracts: + demands DeterministicEvaluation + fulfils EventSubmitter +} +``` + +**Invariant annotations** — `@invariant` inside a contract is a named, scoped prose annotation about a property of the operations in that contract. It carries a PascalCase name and a prose description in indented comment lines. Invariant names must be unique within their contract. + +`@invariant` is distinct from `@guarantee`. `@guarantee` is a surface-level annotation about the boundary contract as a whole. `@invariant` describes a property scoped to a specific contract. The expression-bearing `invariant Name { expression }` construct (no sigil, no colon, brace-delimited body) is a separate form that appears at top-level and entity-level scopes (see [Invariants](#invariants)). + +**Timeout example** — a `timeout` clause references an existing temporal rule by name and binds it to the surface's context. The rule name must correspond to a rule with a temporal trigger defined elsewhere in the spec. The `when` condition is optional: include it to restate the temporal expression for readability, or omit it when the rule name is self-explanatory. When present, the checker verifies the `when` condition matches the referenced rule's trigger. + +``` +surface InvitationView { + facing recipient: Candidate + + context invitation: ResourceInvitation where email = recipient.email + + exposes: + invitation.resource.name + invitation.is_valid + + provides: + AcceptInvitation(invitation, recipient) when invitation.is_valid + + timeout: + InvitationExpires when invitation.expires_at <= now +} +``` + +The rule name alone is sufficient when the temporal condition is clear from the rule's name: + +``` + timeout: + InvitationExpires +``` + +When the `when` condition is included, it serves as inline documentation. The checker verifies it matches the referenced rule's trigger, preventing drift between the surface and the rule. + +--- + +## Validation rules + +A valid Allium specification must satisfy: + +**Structural validity:** +1. All referenced entities and values exist (internal, external or imported) +2. All entity fields have defined types +3. All relationships reference valid entities (singular names) and include a backreference to `this` in their `with` predicate. `with` is used for relationship declarations and must reference `this`; `where` is used for filtering (projections, iteration, surface context, actor identification, surface `let`) and must not reference `this` +4. All rules have at least one trigger and at least one ensures clause +5. All triggers are valid (external stimulus, state transition, state becomes, entity creation, temporal, derived or chained) +6. All rules sharing a trigger name must use the same parameter count and positional types. Parameter binding names may differ between rules. Optional parameters (typed `T?`) may be omitted at call sites; omitted optional parameters bind to `null` + +**State machine validity (without transition graph):** +7. All status values are reachable via some rule +8. All non-terminal status values have exits +9. No undefined states: rules cannot set status to values not in the enum + +**Transition graph validity (when a `transitions` block is declared):** +7a. Rules whose `ensures` clauses produce transitions not in the declared graph are errors (authoritative relationship) +7b. Every non-terminal state in the graph has at least one outbound edge +7c. Every declared edge in the graph is witnessed by at least one rule whose `requires`/`ensures` pair can produce that transition +7d. Every enum value on the field appears in at least one edge or in the `terminal:` clause; every value in the graph exists on the field (exact correspondence) +7e. Terminal states must be explicitly declared with a `terminal:` clause + +**State-dependent field validity (when `when` clauses are present):** +7f. Every state in a `when` clause must be a valid value of the referenced status field +7g. The field referenced in a `when` clause must have a `transitions` block +7h. Rules transitioning into the `when` set (source state outside, target state inside) must set the field +7i. Rules transitioning out of the `when` set (source state inside, target state outside) must clear the field +7j. Transitions within the `when` set (both states inside) or outside it (both states outside) carry no obligation +7k. Accessing a `when`-qualified field without a `requires` guard narrowing to a qualifying state is an error +7l. Optional explicit `when` on derived values must match the checker's inferred intersection of input `when` sets +7m. Tautological invariant (off by default, opt-in): an expression-bearing invariant whose assertion is provably true given lifecycle analysis from `when` clauses and transition reachability + +**Expression validity:** +10. No circular dependencies in derived values +11. All variables are bound before use +12. Type consistency in comparisons and arithmetic +13. All lambdas are explicit (use `i => i.field` not `field`) +14. Inline enum fields cannot be compared with each other (whether on the same entity or across entities); use a named enum to share values across fields +14a. Dot-method calls on collections must use a recognised built-in name (`.count`, `.any()`, `.all()`, `.first`, `.last`, `.unique`, `.add()`, `.remove()`). Unrecognised dot-methods are errors. Domain-specific collection operations use free-standing black box function syntax + +**Sum type validity:** +15. Sum type discriminators use the pipe syntax with capitalised variant names (`A | B | C`) +16. All names in a discriminator field must be declared as `variant X : BaseEntity` +17. All variants that extend a base entity must be listed in that entity's discriminator field +18. Variant-specific fields are only accessed within type guards (`requires:` or `if` branches) +19. Base entities with sum type discriminators cannot be instantiated directly +20. Discriminator field names are user-defined (e.g., `kind`, `node_type`), no reserved name +21. The `variant` keyword is required for variant declarations + +**Given validity:** +22. `given` bindings must reference entity types declared in the module or imported via `use` +23. Each binding name must be unique within the `given` block +24. Unqualified instance references in rules must resolve to a `given` binding, a `let` binding, a trigger parameter or a default entity instance + +**Config validity:** +25. Config parameters must have explicit types. Parameters with default values must declare them explicitly (literal, qualified reference or expression). Parameters without defaults are mandatory: consuming modules must supply a value +26. Config parameter names must be unique within the config block +27. References to `config.field` in rules must correspond to a declared parameter in the local config block or a qualified external config (`alias/config.field`) + +**Surface validity:** +28. Types in `facing` clauses must be either a declared `actor` type or a valid entity type (internal, external or imported) +29. All fields referenced in `exposes` must be reachable from bindings declared in the surface (`facing`, `context`, `let`), via relationships, or be declared types from imported specifications +30. All triggers referenced in `provides` must be defined as external stimulus triggers in rules +31. All surfaces referenced in `related` must be defined, and the type of the parenthesised expression must match the target surface's `context` type +32. Bindings in `facing` and `context` clauses must be used consistently throughout the surface +33. `when` conditions must reference valid fields reachable from the party or context bindings +34. `for` iterations must iterate over collection-typed fields or bindings and are valid in block scopes that produce per-item content (`exposes`, `provides`, `related`) +35. Rule names referenced in `timeout` clauses must correspond to a defined rule with a temporal trigger. If a `when` condition is present, it must match the referenced rule's temporal trigger expression + +**Contract clause validity:** +36. `contracts:` entries must use `demands` or `fulfils` followed by a PascalCase contract name +37. Each contract name appears at most once per surface +38. Referenced contract names must resolve to a `contract` declaration in scope (local or imported via `use`) +39. Same-named contracts from different modules on the same surface are a structural error + +**Contract validity:** +40. `contract` declarations must have a PascalCase name followed by a brace-delimited block body +41. Contract bodies may contain only typed signatures and annotations (`@invariant`, `@guidance`) +42. Types in contract signatures must be declared at module level or imported via `use` +43. Contract names must be unique at module level +44. `@invariant` annotations within contracts must have a PascalCase name and be followed by at least one indented comment line +45. `@invariant` names must be unique within their contract + +**Config reference validity:** +46. A qualified config reference in a default expression must resolve to a declared parameter in an imported module's config block +47. The declared type of a parameter with a qualified default must match the referenced parameter's type +48. The config reference graph must be acyclic + +**Config expression validity:** +49. Expression-form config defaults must use only arithmetic operators (`+`, `-`, `*`, `/`), literal values, local config parameter references and qualified config references +50. Both sides of an arithmetic operator in a config default must resolve to type-compatible operands per the type compatibility table + +**Invariant validity:** +51. Top-level `invariant` blocks must have a PascalCase name followed by a brace-delimited expression body +52. Entity-level `invariant` blocks must have a PascalCase name followed by a brace-delimited expression body +53. Invariant names must be unique within their scope (module-level for top-level invariants, entity declaration for entity-level invariants) +54. Invariant expressions must evaluate to a boolean type +55. Invariant expressions must not contain side-effecting operations (`.add()`, `.remove()`, `.created()`, trigger emissions) +56. Invariant expressions must not reference `now` (volatile; stored timestamp fields are permitted) +57. Entity collection references in top-level invariants must correspond to declared entity types + +**Ordered collection validity:** +58. `.first` and `.last` on unordered collections (`Set`) produce a warning in the current version, becoming a hard error in the next version +59. Set arithmetic (`+`, `-`) on ordered collections produces unordered results. The checker reports an error if the result is used where an ordered collection is expected +60. `.unique` produces an unordered `Set` regardless of the source collection's ordering + +**Enum literal validity:** +61. Backtick-quoted enum literals must contain only printable Unicode characters (categories L, M, N, P, S) excluding backtick and whitespace +62. Backtick-quoted literals are permitted only in enum declarations (named and inline), literal comparisons and `ensures` clauses; they are not permitted in identifier positions +63. Backtick-quoted literals cannot appear in arithmetic expressions + +**Annotation validity:** +64. `@invariant` requires a PascalCase name; names must be unique within their containing construct (contract or surface) +65. `@guarantee` requires a PascalCase name; names must be unique within their surface +66. `@guidance` must not have a name; must appear after all structural clauses and after all other annotations in its containing construct +67. All annotations must be followed by at least one indented comment line; unindented comment lines after an annotation are not part of the annotation body +68. Within a construct, `@invariant` and `@guarantee` annotations may appear in any order relative to each other but must appear after all structural clauses; `@guidance` must appear last + +The checker should warn (but not error) on: +- External entities without known governing specification +- Open questions +- Deferred specifications without location hints +- Unused entities or fields +- Rules that can never fire (preconditions always false) +- Temporal rules without guards against re-firing +- Surfaces that reference fields not used by any rule (may indicate dead code) +- Items in `provides` with `when` conditions that can never be true +- Actor declarations that are never used in any surface +- Rules whose ensures creates an entity for a parent, where sibling rules on the same parent don't guard against that entity's existence +- Surface `provides` when-guards weaker than the corresponding rule's requires +- Rules with the same trigger and overlapping preconditions (spec ambiguity) +- Parameterised derived values that reference fields outside the entity (scoping violation) +- Actor `identified_by` expressions that are trivially always-true or always-false +- Rules where all ensures clauses are conditional and at least one execution path produces no effects +- Temporal triggers on optional fields (trigger will not fire when the field is null) +- Surfaces that use a raw entity type in `facing` when actor declarations exist for that entity type (may indicate a missing access restriction) +- `transitions_to` triggers on values that entities can be created with (the rule will not fire on creation; consider `becomes` if the rule should also fire on creation) +- Multiple fields on the same entity with identical inline enum literals (suggests extraction to a named enum; will error if the fields are later compared) +- `@invariant` prose that resembles a formal expression (informational: promote to expression-bearing `invariant Name { expression }` when the assertion is machine-readable) +- Config reference chains deeper than two levels of indirection +- Diamond dependency conflicts in config overrides +- Tautological invariant (off by default, opt-in): an expression-bearing invariant whose assertion is provably true given lifecycle analysis from `when` clauses and transition reachability +- `.first` or `.last` on unordered collections (warning in current version, error in next) + +--- + +## Anti-patterns + +**Implementation leakage:** +``` +-- Bad +let request = FeedbackRequest.find(interview_id, interviewer_id) + +-- Good +let request = FeedbackRequest{interview, interviewer} +``` + +**UI/UX in spec:** +``` +-- Bad +ensures: Button.displayed(label: "Confirm", onClick: ...) + +-- Good +ensures: CandidateInformed(about: options_available, data: { slots: slots }) +``` + +**Algorithm in rules:** +``` +-- Bad +ensures: selected = filter(take(sortBy(interviewers, load), 3), available) + +-- Good +ensures: Suggestion.created( + interviewers: InterviewerMatching.suggest(considering: [...]) +) +``` + +**Queries in rules:** +``` +-- Bad +let pending = SlotConfirmation.where(slot: slot, status: pending) + +-- Good +let pending = slot.pending_confirmations +``` + +**Implicit shorthand in lambdas:** +``` +-- Bad +interviewers.any(can_solo) + +-- Good +interviewers.any(i => i.can_solo) +``` + +**Missing temporal guards:** +``` +-- Bad: can fire repeatedly +rule InvitationExpires { + when: invitation: Invitation.expires_at <= now + ensures: invitation.status = expired +} + +-- Good: guard prevents re-firing +rule InvitationExpires { + when: invitation: Invitation.expires_at <= now + requires: invitation.status = pending + ensures: invitation.status = expired +} +``` + +**Overly broad status enums:** +``` +-- Bad +status: draft | pending | active | paused | resumed | completed | + cancelled | expired | archived | deleted + +-- Good +status: pending | active | completed | cancelled +is_archived: Boolean +``` + +**`transitions_to` doesn't fire on creation:** +``` +-- Bad: won't fire when Interview is created with status = scheduled +rule NotifyOnScheduled { + when: interview: Interview.status transitions_to scheduled + ensures: Email.created(to: interview.candidate.email, template: interview_scheduled) +} + +-- Good: use becomes when the rule should fire regardless of how the state was reached +rule NotifyOnScheduled { + when: interview: Interview.status becomes scheduled + ensures: Email.created(to: interview.candidate.email, template: interview_scheduled) +} + +-- Also good: handle creation and transition separately when the response differs +rule NotifyOnRescheduled { + when: interview: Interview.status transitions_to scheduled + ensures: Email.created(to: interview.candidate.email, template: interview_rescheduled) +} + +rule NotifyOnCreatedScheduled { + when: interview: Interview.created + requires: interview.status = scheduled + ensures: Email.created(to: interview.candidate.email, template: interview_scheduled) +} +``` + +**Magic numbers in rules:** +``` +-- Bad +requires: attempts < 3 +ensures: deadline = now + 48.hours + +-- Good +requires: attempts < config.max_attempts +ensures: deadline = now + config.confirmation_deadline +``` + +--- + +## Glossary + +| Term | Definition | +|------|------------| +| **Given (module)** | Entity instances a module operates on, declared with `given { ... }`; inherited by all rules in the module. Binds singleton instances at module scope. Contrast with **Context**, which is parametric | +| **Context (surface)** | Parametric scope binding for a boundary contract, declared with `context` inside a surface. Creates one surface instance per matching entity. Contrast with **Given**, which binds singleton instances at module scope | +| **Entity** | A domain concept with identity and lifecycle | +| **Value** | Structured data without identity, compared by structure | +| **Sum Type** | Entity constrained to exactly one of several variants via a discriminator field | +| **Discriminator** | Field whose pipe-separated capitalised values name the variants | +| **Variant** | One alternative in a sum type, declared with `variant X : Base { ... }` | +| **Type Guard** | Condition (`requires:` or `if`) that narrows to a variant, unlocking its fields | +| **Field** | Data stored on an entity or value | +| **Relationship** | Navigation from one entity to related entities. Unordered relationships produce `Set`; ordered relationships produce `Sequence`. Declaration syntax for ordered relationships is pending | +| **Projection** | A filtered view of a relationship. Preserves the ordering of the source collection: a projection of a `Sequence` is a `Sequence` | +| **Sequence** | Ordered collection type that will be produced by ordered relationships and their projections when declaration syntax is introduced (pending follow-up ALP). A subtype of `Set`: assignable where an unordered collection is expected, but not the reverse. Distinct from `List`, which is a compound field type declared explicitly. Ordering propagates through `where` and field extraction; set arithmetic (`+`, `-`) and `.unique` produce unordered results | +| **Ordered collection** | A collection whose elements have a meaningful sequence (intrinsic order). `Sequence` and `List` are the two ordered collection types. `.first`, `.last` and deterministic `for` iteration are restricted to ordered collections | +| **Derived Value** | A computed value based on other fields | +| **Parameterised Derived Value** | A derived value that takes arguments, e.g. `can_use_feature(f): f in plan.features` | +| **Rule** | A specification of behaviour triggered by some condition | +| **Trigger** | The condition that causes a rule to fire | +| **Trigger Emission** | An ensures clause that emits a named event; other rules chain from it via their `when` clause | +| **Precondition** | A requirement that must be true for a rule to execute | +| **Postcondition** | An assertion about what becomes true after a rule executes | +| **`when` clause (field)** | Clause on a field declaration tying its presence to lifecycle state: `field: Type when status = value1 \| value2`. The field is present only in the listed states. The referenced status field must have a `transitions` block. Orthogonal to `?` (genuine optionality) | +| **Presence obligation** | When a rule transitions an entity into a field's `when` set (source state outside, target state inside), the rule must set the field | +| **Absence obligation** | When a rule transitions an entity out of a field's `when` set (source state inside, target state outside), the rule must clear the field | +| **Derived value `when` inference** | The checker infers `when` sets for derived values by intersecting the `when` sets of their inputs. Authors may optionally annotate with an explicit `when` clause, verified against the inference. The checker exports inferred `when` sets as structured data | +| **Black Box Function** | Domain logic referenced but not defined in the spec; pure and deterministic. Always use free-standing call syntax, never dot-method syntax. For collection-operating functions, the collection is the first argument: `filter(events, predicate)`. Common examples include `hash()`, `verify()`, `filter()`, `grouped_by()` | +| **External Entity** | An entity managed by another specification; referenced but not governed here | +| **Config** | Configurable parameters for a specification, referenced via `config.field` | +| **Default** | A named entity instance used as seed data or base configuration | +| **Deferred Specification** | Complex logic defined in a separate file | +| **Open Question** | An unresolved design decision | +| **Entity Collection** | Pluralised type name referring to all instances of that entity (e.g., `Users` for all `User` instances) | +| **Exists** | Keyword for checking entity existence (`exists x`) or asserting removal (`not exists x`) | +| **`within`** | Clause in actor declarations that names the required context type; also a keyword in `identified_by` expressions that resolves to the surface's context entity | +| **`this`** | The instance of the enclosing type; valid in entity declarations and actor `identified_by` expressions | +| **Enum** | A set of values. **Named enums** (`enum Recommendation { ... }`) have type identity and are reusable across fields and entities. **Inline enums** (`status: pending \| active`) are anonymous, scoped to a single field, and cannot be compared across fields. Enum literals referencing external standards may use backtick quoting (`` `de-CH-1996` ``) to preserve the standard's canonical form; quoted and unquoted literals are distinct values with no implicit normalisation | +| **Transition Graph** | An authoritative, opt-in declaration of valid lifecycle transitions for an enum status field. Declared inside the entity body with `transitions field_name { ... }` using `->` edge notation and a `terminal:` clause. When present, rules producing transitions not in the graph are validation errors. Entities without a graph derive transition validity from rules alone | +| **Discard Binding** | `_` used where a binding is syntactically required but the value is not needed | +| **Actor** | An entity type that can interact with surfaces, declared with explicit identity mapping | +| **`facing`** | Surface clause naming the external party on the other side of the boundary | +| **Surface** | A boundary contract between two parties specifying what each side exposes and provides, with optional `contracts:` clause for programmatic integration obligations | +| **Contract** | A named, direction-agnostic obligation declared at module level with `contract Name { ... }`. Surfaces reference contracts in a `contracts:` clause with `demands`/`fulfils` direction markers. Identity determined by module-qualified name | +| **`demands`** | Direction marker in a `contracts:` clause indicating the counterpart must implement this contract | +| **`fulfils`** | Direction marker in a `contracts:` clause indicating this surface supplies the contract's operations | +| **Invariant** | A named, scoped assertion about a property. Two syntactic forms: `@invariant Name` (prose annotation, in contracts) and `invariant Name { expression }` (expression-bearing, at top-level and entity-level). Expression-bearing invariants are logical assertions over entity state, not runtime checks. Distinct from `@guarantee`, which annotates properties of the boundary as a whole | +| **`@guarantee`** | Named prose annotation on a surface asserting a property of the boundary as a whole. PascalCase name required, unique within the surface. Structurally validated by the checker; prose content is not evaluated. Distinct from `@invariant` (scoped to a contract) and expression-bearing `invariant Name { }` (machine-readable) | +| **`@guidance`** | Unnamed prose annotation providing non-normative implementation advice. Permitted in contracts, rules and surfaces. Must appear after all structural clauses and after all other annotations in its containing construct. Structurally validated; prose content is not evaluated | +| **`implies`** | Boolean operator. `a implies b` is `not a or b`. Lowest boolean precedence, binding looser than `and` and `or`. Available in all expression contexts | +| **Config reference** | A qualified reference in a config default (`param: Type = other/config.param`) that aliases a parameter from an imported module. Supports expression-form defaults with arithmetic operators | diff --git a/plugins/allium/skills/allium/references/migration-v1-to-v2.md b/plugins/allium/skills/allium/references/migration-v1-to-v2.md new file mode 100644 index 0000000..01c2710 --- /dev/null +++ b/plugins/allium/skills/allium/references/migration-v1-to-v2.md @@ -0,0 +1,327 @@ +# Migrating from Allium v1 to v2 + +This guide covers every change between Allium v1 and v2. It is written for both humans reviewing the release and LLMs tasked with upgrading v1 specifications. + +If you are an LLM migrating a v1 spec, read this document in full, then work through the checklist at the end. The checklist verifies completeness but does not repeat the syntax rules and examples you will need from the sections above it. + +--- + +## What changed + +Version 2 adds six capabilities to the language. None of the existing v1 syntax was removed or altered; every v1 construct still means what it meant before. The changes are: + +1. **Contract references** (`demands`, `fulfils`) in surfaces, for expressing programmatic integration contracts with typed signatures and invariants. +2. **Module-level contracts** (`contract`), direction-agnostic obligation declarations that surfaces reference via a `contracts:` clause. +3. **Guidance annotations** (`@guidance`) in rules, contracts and surfaces, for non-normative implementation advice. +4. **Expression-bearing invariants** (`invariant Name { expression }`), machine-readable assertions at top-level and entity-level scope. +5. **The `implies` operator**, a boolean operator available in all expression contexts. +6. **Config composition** — config parameter defaults that reference imported module parameters by qualified name, with arithmetic expressions for derived defaults. + +Because all changes are additive, a v1 spec is valid v2 once the version marker is updated. No existing syntax needs rewriting. + +--- + +## Required changes + +### 1. Update the version marker + +The first line of every `.allium` file must change from version 1 to version 2. This is the only change required for every spec. + +v1: +``` +-- allium: 1 +``` + +v2: +``` +-- allium: 2 +``` + +### 2. Adjust section order (only when adopting new constructs) + +V2 introduces two new sections. The full section order is now: + +``` +use declarations +Given +External Entities +Value Types +Contracts ← new, between Value Types and Enumerations +Enumerations +Entities and Variants +Config +Defaults +Rules +Invariants ← new, between Rules and Actor Declarations +Actor Declarations +Surfaces +Deferred Specifications +Open Questions +``` + +Empty sections are still omitted. No existing sections moved: the two new sections slot between existing ones. If your v1 spec does not adopt contracts or expression-bearing invariants, no section headers need adding and the existing order is already correct. + +If you add contracts, place the section header after Value Types: + +``` +------------------------------------------------------------ +-- Contracts +------------------------------------------------------------ +``` + +If you add expression-bearing invariants, place the section header after Rules: + +``` +------------------------------------------------------------ +-- Invariants +------------------------------------------------------------ +``` + +--- + +## New constructs available in v2 + +These constructs did not exist in v1. They are optional: a migrated spec does not need to use them. But they are available, and specs that would benefit from them should adopt them. + +### Contract references in surfaces (`demands`, `fulfils`) + +V1 surfaces had `exposes`, `provides`, `guarantee`, `related` and `timeout`. V2 adds a `contracts:` clause for programmatic integration contracts. + +Use `demands` when the surface requires something from the counterpart. Use `fulfils` when the surface supplies something to the counterpart. Each entry references a module-level `contract` declaration by name. + +``` +contract DeterministicEvaluation { + evaluate: (event_name: String, payload: ByteArray) -> EventOutcome + + @invariant Determinism + -- For identical inputs, evaluate must produce + -- byte-identical outputs across all instances. + + @guidance + -- Avoid allocating during evaluation where possible. +} + +contract EventSubmitter { + submit: (key: String, event_name: String, payload: ByteArray) -> EventSubmission +} + +surface DomainIntegration { + facing framework: FrameworkRuntime + + contracts: + demands DeterministicEvaluation + fulfils EventSubmitter +} +``` + +**Syntax rules:** +- `contracts:` entries use `demands` or `fulfils` followed by a PascalCase contract name. +- Each contract name may appear at most once per surface. +- Referenced contract names must resolve to a `contract` declaration in scope. +- Contract bodies contain typed signatures and `@`-prefixed annotations (`@invariant`, `@guidance`). No entity, value, enum or variant declarations. + +**When to add contract references to an existing v1 surface:** if the surface describes a boundary between code (framework and module, service and plugin, API and consumer) rather than between a user and an application, and the contract involves typed operations with specific properties. + +### Module-level contracts + +Contracts are declared at module level in the Contracts section. Surfaces reference them via the `contracts:` clause. + +``` +-- Module-level declaration (in the Contracts section) +contract Codec { + serialize: (value: Any) -> ByteArray + deserialize: (bytes: ByteArray) -> Any + + @invariant Roundtrip + -- deserialize(serialize(value)) produces a value + -- equivalent to the original for all supported types. +} + +contract EventSubmitter { + submit: (event: DomainEvent) -> Acknowledgement +} + +-- Surface references contracts with direction markers +surface DataPipeline { + facing processor: ProcessorModule + + contracts: + demands Codec + fulfils EventSubmitter +} +``` + +**Syntax rules:** +- `contracts:` entries use `demands` or `fulfils` followed by a contract name. +- Contract identity is determined by module-qualified name. Same-named contracts from different modules are a structural error. +- Contracts are imported atomically via `use`. Partial imports are not supported. + +### Guidance annotations in rules + +Rules can now end with an `@guidance` annotation containing non-normative implementation advice. + +``` +-- v1: no guidance clause +rule ExpireInvitation { + when: invitation: Invitation.expires_at <= now + requires: invitation.status = pending + ensures: invitation.status = expired +} + +-- v2: guidance added as final annotation +rule ExpireInvitation { + when: invitation: Invitation.expires_at <= now + requires: invitation.status = pending + ensures: invitation.status = expired + + @guidance + -- Expire in a background job rather than blocking the + -- request path. Batch expiration where possible. +} +``` + +**Syntax rules:** +- `@guidance` must appear after all structural clauses and after all other annotations in its containing construct. +- Content is opaque prose using indented comment syntax (`--`). The checker does not parse it. +- `@guidance` is also valid inside contracts and at surface level. In contracts it provides implementation advice scoped to that contract's operations. At surface level it provides advice about the boundary as a whole. +- The `@` sigil marks prose annotations: constructs whose structure (placement, ordering) the checker validates, but whose content it does not evaluate. The same sigil convention applies to `@invariant` and `@guarantee`. + +### Expression-bearing invariants + +V1 had no mechanism for machine-readable assertions over entity state. V2 adds expression-bearing invariants at two scopes. + +**Top-level invariants** assert system-wide properties. They go in the new Invariants section after Rules: + +``` +invariant NonNegativeBalance { + for account in Accounts: + account.balance >= 0 +} + +invariant UniqueEmail { + for a in Users: + for b in Users: + a != b implies a.email != b.email +} +``` + +**Entity-level invariants** assert properties scoped to a single entity. They go inside entity declarations alongside fields: + +``` +entity Account { + balance: Decimal + credit_limit: Decimal + status: active | frozen | closed + + invariant SufficientFunds { + balance >= -credit_limit + } + + invariant FrozenAccountsCannotTransact { + status = frozen implies pending_transactions.count = 0 + } +} +``` + +**Syntax rules:** +- Expression-bearing invariants use `invariant Name { expression }` (no `@`, braces). +- Prose-only invariants in contracts use `@invariant Name` (with `@`, no colon). These are distinct constructs. +- Invariant names are PascalCase. +- Expressions must be pure: no `.add()`, `.remove()`, `.created()`, no trigger emissions, no `now`. +- `for x in Collection:` inside an invariant body is a universal quantifier (all elements must satisfy). + +**When to add invariants to a migrated spec:** if the spec has properties that should always hold (non-negative balances, uniqueness constraints, referential integrity) and those properties are currently implicit or expressed only in prose comments. + +### The `implies` operator + +V2 adds `implies` to the expression language. `a implies b` is equivalent to `not a or b`. It has the lowest precedence of any boolean operator, binding looser than `and` and `or`. + +`implies` is available in all expression contexts, not only invariants. It reads naturally in `requires` guards, derived boolean values and `if` conditions: + +``` +-- In a requires clause +requires: user.role = admin implies user.mfa_enabled + +-- In a derived value +is_compliant: is_verified implies documents.count > 0 + +-- In an invariant +invariant ClosedAccountsEmpty { + for account in Accounts: + account.status = closed implies account.balance = 0 +} +``` + +### Config parameter references and expressions + +V1 config parameters could only have literal defaults. V2 allows defaults to reference parameters from imported modules, and to use arithmetic expressions. + +``` +use "./core.allium" as core + +config { + -- Literal default (valid in both v1 and v2) + max_retries: Integer = 3 + + -- Qualified reference default (v2 only) + batch_size: Integer = core/config.batch_size + + -- Expression default (v2 only) + extended_timeout: Duration = core/config.base_timeout * 2 + buffer_size: Integer = core/config.batch_size + 10 + retry_limit: Integer = max_retries - 1 +} +``` + +**Syntax rules:** +- Qualified references use the form `alias/config.param_name`. +- Arithmetic operators: `+`, `-`, `*`, `/` with standard precedence. Parentheses for explicit precedence. +- Both local and qualified references are valid in expressions. +- The config reference graph must be acyclic. +- Type compatibility: Integer with Integer, Duration with Duration (for `+`/`-`), Duration with Integer (for `*`/`/`), Integer with Duration (for `*` only), Decimal with Decimal, Decimal with Integer (for `*`/`/`), Integer with Decimal (for `*` only). Scalar multiplication is commutative (`2 * core/config.timeout` and `core/config.timeout * 2` are both valid). Addition and subtraction require matching types. +- Expressions resolve once at config resolution time, not dynamically. + +**When to use config references in a migrated spec:** when a consuming spec duplicates a library spec's config value, or derives a value from it (double the timeout, batch size minus a buffer). + +--- + +## Naming convention additions + +V2 extends PascalCase to two new constructs: + +| Construct | Convention | Example | +|-----------|-----------|---------| +| Contract names | PascalCase | `Codec` | +| Invariant names | PascalCase | `NonNegativeBalance` | + +All other naming conventions are unchanged from v1. + +--- + +## Migration checklist + +Use this checklist when upgrading a v1 spec to v2. Items marked **required** must be done. Items marked **optional** should be done when the spec would benefit. + +- [ ] **Required.** Change `-- allium: 1` to `-- allium: 2` on the first line. +- [ ] **Required if adopting new constructs.** Verify section order matches v2 (Contracts after Value Types, Invariants after Rules). If neither section is present, existing order is already correct. +- [ ] **Optional.** If the spec has surfaces describing code-to-code boundaries, consider declaring `contract` blocks and referencing them via a `contracts:` clause with `demands`/`fulfils`. +- [ ] **Optional.** If rules or surfaces have implementation-specific notes in comments, consider moving them into `@guidance` annotations (valid as the final annotation in rules and at surface level). +- [ ] **Optional.** If the spec has properties that must always hold (uniqueness, non-negativity, referential constraints), express them as `invariant Name { expression }` blocks. +- [ ] **Optional.** If any expression (invariants, requires, derived values) would read more clearly with implication logic, use the `implies` operator. +- [ ] **Optional.** If config defaults duplicate or derive from imported module parameters, use qualified references and expressions. + +--- + +## Quick reference + +| V1 | V2 | Change type | +|----|-----|-------------| +| `-- allium: 1` | `-- allium: 2` | Required | +| Sections: Value Types → Enumerations | Sections: Value Types → **Contracts** → Enumerations | Required (if contracts present) | +| Sections: Rules → Actor Declarations | Sections: Rules → **Invariants** → Actor Declarations | Required (if invariants present) | +| No contract references in surfaces | `contracts:` clause with `demands`/`fulfils` entries | Additive | +| No module-level contracts | `contract Name { ... }` in Contracts section | Additive | +| No `@guidance` annotation | `@guidance` in rules (final annotation), contracts and surfaces | Additive | +| No expression-bearing invariants | `invariant Name { expression }` at top-level and entity-level | Additive | +| No `implies` operator | `a implies b` (lowest boolean precedence) | Additive | +| Config defaults are literals only | Config defaults can reference `alias/config.param` and use arithmetic | Additive | diff --git a/plugins/allium/skills/allium/references/migration-v2-to-v3.md b/plugins/allium/skills/allium/references/migration-v2-to-v3.md new file mode 100644 index 0000000..481b334 --- /dev/null +++ b/plugins/allium/skills/allium/references/migration-v2-to-v3.md @@ -0,0 +1,264 @@ +# Migrating from Allium v2 to v3 + +This guide covers every change between Allium v2 and v3. It is written for both humans reviewing the release and LLMs tasked with upgrading v2 specifications. + +If you are an LLM migrating a v2 spec, read this document in full, then work through the checklist at the end. The checklist verifies completeness but does not repeat the syntax rules and examples you will need from the sections above it. + +--- + +## What changed + +Version 3 adds six capabilities and one enforcement change to the language. All v2 constructs retain their meaning. The changes are: + +1. **Transition graphs** (`transitions field_name { ... }`), authoritative opt-in declarations of valid lifecycle transitions for enum status fields. +2. **State-dependent field presence** (`when` clause on field declarations), tying a field's presence to the entity's lifecycle state rather than using static `?` optionality. +3. **Derived value `when` propagation**, automatic inference of `when` sets for derived values computed from state-dependent fields. +4. **Backtick-quoted enum literals** (`` `de-CH-1996` ``), allowing external standard values that fall outside snake_case conventions. +5. **Ordered collection semantics** (`Sequence`), distinguishing ordered from unordered collections and restricting `.first`/`.last` to ordered types. +6. **Black box function syntax for collection operations**, reserving dot-method syntax for built-in operations and requiring free-standing call syntax for domain-specific collection operations. + +Because the first five changes are additive, a v2 spec that does not use custom dot-methods on collections is valid v3 once the version marker is updated. Specs that use custom dot-methods need rewriting (see [Enforcement change](#enforcement-change-black-box-collection-operations) below). + +--- + +## Required changes + +### 1. Update the version marker + +The first line of every `.allium` file must change from version 2 to version 3. + +v2: +``` +-- allium: 2 +``` + +v3: +``` +-- allium: 3 +``` + +### 2. Rewrite custom dot-methods on collections (if present) + +V3 reserves dot-method syntax on collections for built-in operations only. The full set of built-in dot-methods is: `.count`, `.any()`, `.all()`, `.first`, `.last`, `.unique`, `.add()`, `.remove()`. Any other dot-method call on a collection is now a checker error. + +If your v2 spec used dot-method syntax for domain-specific collection operations, rewrite them to free-standing black box function syntax with the collection as the first argument: + +v2: +``` +events.filter(e => e.recent) +copies.grouped_by(r => r.output_payloads) +pending.min_by(e => e.offset) +``` + +v3: +``` +filter(events, e => e.recent) +grouped_by(copies, r => r.output_payloads) +min_by(pending, e => e.offset) +``` + +If your v2 spec did not use custom dot-methods, no rewriting is needed. + +--- + +## New constructs available in v3 + +These constructs did not exist in v2. They are optional: a migrated spec does not need to use them. But they are available, and specs that would benefit from them should adopt them. + +### Transition graphs + +V2 entities derived their valid transitions implicitly from the rules that operated on them. V3 adds an opt-in mechanism for declaring the valid transitions explicitly, inside the entity body. + +``` +entity Order { + status: pending | confirmed | shipped | delivered | cancelled + + transitions status { + pending -> confirmed + confirmed -> shipped + shipped -> delivered + pending -> cancelled + confirmed -> cancelled + terminal: delivered, cancelled + } +} +``` + +When a transition graph is declared, it is authoritative: rules whose `ensures` clauses produce transitions not in the graph are validation errors. The checker also enforces that every non-terminal state has at least one outbound edge and that every declared edge is witnessed by at least one rule. + +**Syntax rules:** +- The graph lives inside the entity body, below the field it governs, introduced by `transitions field_name`. +- Each line in the block is a directed edge: `from_state -> to_state`. +- Terminal states are declared with `terminal:` followed by a comma-separated list. Absence of outbound edges does not imply terminal status; the declaration is required. +- Every value on the enum field must appear in at least one edge or as a terminal. Every value in the graph must exist on the field. Drift is a hard error. +- Entities with multiple status fields use independent single-field graphs. +- Entities without a declared graph continue to derive transition validity from rules alone, with no change in checker behaviour. The checker does not suggest adding graphs to entities that lack them. + +**When to add transition graphs to a migrated spec:** when the entity has a lifecycle field with well-understood valid transitions and you want the checker to enforce them. Particularly valuable for entities where incorrect transitions would be hard to detect from rule inspection alone. + +### State-dependent field presence (`when` clause) + +V2 used `?` to mark fields that might be absent. In lifecycle entities, many fields are absent in some states and guaranteed present in others, but `?` cannot express this distinction. V3 adds a `when` clause on field declarations that ties presence to lifecycle state. + +``` +entity Document { + status: active | deleted + deleted_at: Timestamp when status = deleted + deleted_by: User when status = deleted + + transitions status { + active -> deleted + deleted -> active + terminal: deleted + } +} +``` + +Fields without `when` are present in all states. Fields with `when` are present only when the named status field holds one of the listed values. The `when` clause references a single status field; that field must have a `transitions` block. + +**Presence and absence obligations.** The checker enforces obligations at transition boundaries: + +- **Entering** the `when` set (source state outside, target state inside): the rule must set the field. +- **Leaving** the `when` set (source state inside, target state outside): the rule must clear the field (set to `null`). +- **Moving within** or **outside** the `when` set: no obligation. + +``` +rule SoftDelete { + when: SoftDelete(document, actor) + requires: document.status = active + ensures: + document.status = deleted + document.deleted_at = now -- entering when set: must set + document.deleted_by = actor -- entering when set: must set +} + +rule RestoreDocument { + when: RestoreDocument(document) + requires: document.status = deleted + ensures: + document.status = active + document.deleted_at = null -- leaving when set: must clear + document.deleted_by = null -- leaving when set: must clear +} +``` + +Accessing a `when`-qualified field without a `requires` guard narrowing to a qualifying state is an error. + +**`?` and `when` are orthogonal.** `reviewer_notes: String? when review = approved | rejected` means the field exists in those states but may be null within them. `?` is genuine optionality; `when` is lifecycle-dependent presence. A field may carry both. + +**When to adopt `when` clauses in a migrated spec:** when existing `?` fields are not genuinely optional but are instead absent before a certain lifecycle stage and guaranteed present after it. The soft-delete pattern, order fulfilment pipelines and invitation workflows are common candidates. Replace the `?` with a `when` clause referencing the appropriate status values, and add a `transitions` block if one does not already exist. + +### Derived value `when` propagation + +Derived values computed from `when`-qualified fields automatically inherit the intersection of their inputs' `when` sets: + +``` +entity Order { + status: pending | confirmed | shipped | delivered + shipped_at: Timestamp when status = shipped | delivered + delivery_confirmed_at: Timestamp when status = delivered + + transitions status { + pending -> confirmed + confirmed -> shipped + shipped -> delivered + terminal: delivered + } + + -- Inferred: when status = delivered + -- (intersection of {shipped, delivered} and {delivered}) + days_in_transit: delivery_confirmed_at - shipped_at +} +``` + +The checker infers this; the author does not declare it. An author may optionally annotate a derived value with an explicit `when` clause as documentation. When present, the checker verifies it matches the inferred set. A mismatch is an error. + +### Backtick-quoted enum literals + +V2 enum literals were restricted to snake_case. V3 allows backtick quoting for values that reference external standards with non-snake_case characters: + +``` +enum InterfaceLanguage { en | de | fr | `de-CH-1996` | es | `zh-Hant-TW` | `sr-Latn` } +enum CacheDirective { `no-cache` | `no-store` | `must-revalidate` | `max-age` } +``` + +**Syntax rules:** +- Backtick-quoted literals are values, not identifiers. They participate in equality comparison and assignment. +- The checker does not apply case convention rules inside backticks. Comparison is byte-exact after UTF-8 encoding. +- Quoted and unquoted forms are distinct values with no implicit normalisation: `de_ch_1996` and `` `de-CH-1996` `` are different values. +- Backtick-quoted literals are permitted in enum declarations (named and inline), literal comparisons in rules and `ensures` clauses. +- They are not permitted in identifier positions (field names, entity names, rule names, etc.) and cannot appear in arithmetic expressions. + +**When to use backtick-quoted literals in a migrated spec:** when enum values reference external standards (BCP 47 language tags, MIME types, HTTP cache directives, currency codes) whose canonical form uses hyphens, dots, mixed case or leading digits. Replace any workaround encodings (underscore-substituted forms) with the standard's canonical form in backticks. + +### Ordered collection semantics + +V2 treated all collections uniformly. V3 introduces a type distinction between ordered and unordered collections: + +- `Set` — unordered collection of unique items (unchanged from v2) +- `List` — ordered collection, declared explicitly as a compound field type on entities +- `Sequence` — ordered collection produced by ordered relationships and their projections. A subtype of `Set`: assignable where an unordered collection is expected, but not the reverse + +**Syntax rules:** +- `.first` and `.last` are restricted to ordered collections (`Sequence` or `List`). Using them on a `Set` is a warning in v3, becoming a hard error in the next version. +- `.unique` deduplicates a collection but always produces an unordered `Set`, even when the source is ordered. +- Set arithmetic (`+`, `-`) on ordered collections produces unordered results. The checker reports an error if the result is used where an ordered collection is expected. +- `for item in collection:` iterates in declared order when the source is a `Sequence` or `List`. When the source is a `Set`, iteration order is unspecified. +- Projections preserve ordering: if the source is a `Sequence`, `where` filtering and `-> field` extraction produce a `Sequence` in the same relative order. + +**When to adopt ordered semantics in a migrated spec:** when the order of items in a collection carries domain meaning (priority lists, attempt sequences, ranked preferences). If order does not matter, continue using `Set`. + +### Enforcement change: black box collection operations + +V3 reserves dot-method syntax on collections for the built-in set: `.count`, `.any()`, `.all()`, `.first`, `.last`, `.unique`, `.add()`, `.remove()`. The checker rejects any other dot-method call on a collection. + +Domain-specific collection operations must use free-standing black box function syntax with the collection as the first argument: + +``` +-- Built-in: dot-method syntax (unchanged) +interviewers.any(i => i.can_solo) +confirmations.all(c => c.status = confirmed) +slots.count + +-- Domain-specific: free-standing syntax (enforced in v3) +filter(events, e => e.recent) +grouped_by(copies, r => r.output_payloads) +min_by(pending, e => e.offset) +flatMap(groups, g => g.deferred_events) +``` + +This was the recommended convention in v2 but was not enforced. V3 makes it a hard error. + +--- + +## Naming convention additions + +V3 does not add new naming conventions. All naming rules are unchanged from v2. Backtick-quoted enum literals are exempt from case convention rules (the checker does not apply snake_case rules inside backticks). + +--- + +## Migration checklist + +Use this checklist when upgrading a v2 spec to v3. Items marked **required** must be done. Items marked **optional** should be done when the spec would benefit. + +- [ ] **Required.** Change `-- allium: 2` to `-- allium: 3` on the first line. +- [ ] **Required if applicable.** Rewrite any custom dot-method calls on collections to free-standing black box function syntax. +- [ ] **Optional.** If entities have lifecycle fields with well-understood valid transitions, add `transitions field_name { ... }` blocks. +- [ ] **Optional.** If fields are typed `?` but are structurally absent before a lifecycle stage and present after it, replace `?` with a `when` clause and ensure the referenced status field has a `transitions` block. +- [ ] **Optional.** If enum values reference external standards with non-snake_case characters, replace workaround encodings with backtick-quoted canonical forms. +- [ ] **Optional.** If collection order carries domain meaning, adopt `List` for explicitly ordered fields and note that relationships producing `Sequence` will have ordering semantics when the ordered relationship declaration syntax is introduced. +- [ ] **Optional.** Review `.first` and `.last` usage on `Set` collections. These produce a warning in v3 and will become errors in the next version. Replace with explicit ordering or remove. + +--- + +## Quick reference + +| V2 | V3 | Change type | +|----|-----|-------------| +| `-- allium: 2` | `-- allium: 3` | Required | +| No transition graph syntax | `transitions field_name { from -> to; terminal: ... }` | Additive | +| `deleted_at: Timestamp?` (static optionality) | `deleted_at: Timestamp when status = deleted` (state-dependent) | Additive | +| No derived value `when` propagation | Derived values inherit intersected `when` sets from inputs | Additive | +| Enum literals restricted to snake_case | Backtick-quoted literals for external standards (`` `de-CH-1996` ``) | Additive | +| All collections treated uniformly | `Set` (unordered), `List` and `Sequence` (ordered) | Additive | +| Custom dot-methods on collections permitted | Dot-methods reserved for built-ins; custom ops use free-standing syntax | Enforcement | diff --git a/plugins/allium/skills/allium/references/patterns.md b/plugins/allium/skills/allium/references/patterns.md new file mode 100644 index 0000000..571ee33 --- /dev/null +++ b/plugins/allium/skills/allium/references/patterns.md @@ -0,0 +1,2950 @@ +# Complete patterns + +This library contains reusable patterns for common SaaS scenarios. Each pattern demonstrates specific Allium language features and can be adapted to your domain. + +Patterns elide common cross-cutting entities (`Email`, `Notification`, `AuditLog`, etc.) for brevity. In a real specification, declare these as external entities or define them in a shared module. + +| Pattern | Key Features Demonstrated | +|---------|---------------------------| +| Password Auth with Reset | Temporal triggers, token lifecycle, defaults, surfaces | +| Role-Based Access Control | Derived permissions, relationships, `requires` checks, surfaces | +| Invitation to Resource | Join entities, permission levels, tokenised actions, surfaces | +| Soft Delete & Restore | State machines, projections filtering deleted items | +| Notification Preferences | Sum types for notification variants, user preferences, digest batching, surfaces | +| Usage Limits & Quotas | Limit checks in `requires`, metered resources, plan tiers, surfaces | +| Comments with Mentions | Nested entities, parsing triggers, cross-entity notifications, surfaces | +| Integrating Library Specs | External spec references, configuration, config parameter references, responding to external triggers | +| Framework Integration Contract | Contract declarations, expression-bearing invariants, contract references, programmatic surfaces | + +--- + +## Pattern 1: Password Authentication with Reset + +**Demonstrates:** Temporal triggers, token lifecycle, defaults, surfaces, multiple related rules + +This pattern handles user registration, login and password reset: the foundation of most SaaS applications. + +``` +-- allium: 3 +-- password-auth.allium + +config { + min_password_length: Integer = 12 + max_login_attempts: Integer = 5 + lockout_duration: Duration = 15.minutes + reset_token_expiry: Duration = 1.hour + session_duration: Duration = 24.hours +} + +------------------------------------------------------------ +-- Entities +------------------------------------------------------------ + +entity User { + email: String + password_hash: String -- stored, never exposed + status: active | locked | deactivated + failed_login_attempts: Integer + locked_until: Timestamp? + + -- Relationships + sessions: Session with user = this + reset_tokens: PasswordResetToken with user = this + + -- Projections + active_sessions: sessions where status = active + pending_reset_tokens: reset_tokens where status = pending + + -- Derived + is_locked: status = locked and locked_until > now +} + +entity Session { + user: User + created_at: Timestamp + expires_at: Timestamp + status: active | expired | revoked + + -- Derived + is_valid: status = active and expires_at > now +} + +entity PasswordResetToken { + user: User + created_at: Timestamp + expires_at: Timestamp + status: pending | used | expired + + -- Derived + is_valid: status = pending and expires_at > now +} + +------------------------------------------------------------ +-- Registration +------------------------------------------------------------ + +rule Register { + when: UserRegisters(email, password) + + requires: not exists User{email: email} + requires: length(password) >= config.min_password_length + + ensures: User.created( + email: email, + password_hash: hash(password), -- black box + status: active, + failed_login_attempts: 0 + ) + ensures: Email.created( + to: email, + template: welcome + ) +} + +------------------------------------------------------------ +-- Login +------------------------------------------------------------ + +rule LoginSuccess { + when: UserLogsIn(email, password) + + let user = User{email} + + requires: exists user + requires: not user.is_locked + requires: verify(password, user.password_hash) -- black box + + ensures: user.failed_login_attempts = 0 + ensures: Session.created( + user: user, + created_at: now, + expires_at: now + config.session_duration, + status: active + ) +} + +rule LoginFailure { + when: UserLogsIn(email, password) + + let user = User{email} + + requires: exists user + requires: not user.is_locked + requires: not verify(password, user.password_hash) + + ensures: user.failed_login_attempts = user.failed_login_attempts + 1 + ensures: + if user.failed_login_attempts >= config.max_login_attempts: + user.status = locked + user.locked_until = now + config.lockout_duration + Email.created(to: user.email, template: account_locked) +} + +rule LoginAttemptWhileLocked { + when: UserLogsIn(email, password) + + let user = User{email} + + requires: exists user + requires: user.is_locked + + ensures: UserInformed( + user: user, + about: account_locked, + data: { unlocks_at: user.locked_until } + ) +} + +rule LockoutExpires { + when: user: User.locked_until <= now + + requires: user.status = locked + + ensures: user.status = active + ensures: user.failed_login_attempts = 0 + ensures: user.locked_until = null +} + +------------------------------------------------------------ +-- Logout +------------------------------------------------------------ + +rule Logout { + when: UserLogsOut(session) + + requires: session.status = active + + ensures: session.status = revoked +} + +rule SessionExpires { + when: session: Session.expires_at <= now + + requires: session.status = active + + ensures: session.status = expired +} + +------------------------------------------------------------ +-- Password Reset +------------------------------------------------------------ + +rule RequestPasswordReset { + when: UserRequestsPasswordReset(email) + + let user = User{email} + + requires: exists user + requires: user.status in {active, locked} + + -- Invalidate any existing tokens + ensures: + for t in user.pending_reset_tokens: + t.status = expired + + ensures: + let token = PasswordResetToken.created( + user: user, + created_at: now, + expires_at: now + config.reset_token_expiry, + status: pending + ) + Email.created( + to: email, + template: password_reset, + data: { token: token } + ) +} + +rule CompletePasswordReset { + when: UserResetsPassword(token, new_password) + + requires: token.is_valid + requires: length(new_password) >= config.min_password_length + + let user = token.user + + ensures: token.status = used + ensures: user.password_hash = hash(new_password) + ensures: user.status = active + ensures: user.failed_login_attempts = 0 + ensures: user.locked_until = null + + -- Invalidate all existing sessions + ensures: + for s in user.active_sessions: + s.status = revoked + + ensures: Email.created( + to: user.email, + template: password_changed + ) +} + +rule ResetTokenExpires { + when: token: PasswordResetToken.expires_at <= now + + requires: token.status = pending + + ensures: token.status = expired +} + +------------------------------------------------------------ +-- Actors +------------------------------------------------------------ + +actor AuthenticatedUser { + identified_by: User where active_sessions.count > 0 +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +surface Authentication { + facing visitor: User + + provides: + UserLogsIn(email, password) + UserRegisters(email, password) + UserRequestsPasswordReset(email) + + @guarantee NoSessionRequired + -- Accessible without an existing session. + + @guidance + -- Show lockout status and unlock time when user.is_locked. + -- Validate password length client-side before submission. +} + +surface PasswordReset { + facing visitor: User + + context token: PasswordResetToken + + exposes: + token.is_valid + token.expires_at + + provides: + UserResetsPassword(token, new_password) + when token.is_valid + + @guarantee NoSessionRequired + -- Accessible without an existing session. +} + +surface AccountManagement { + facing user: AuthenticatedUser + + exposes: + user.email + user.active_sessions + user.active_sessions.count + + provides: + for session in user.active_sessions: + UserLogsOut(session) + UserRequestsPasswordReset(user.email) +} +``` + +**Key language features shown:** +- `config` block for configurable parameters (`config.min_password_length`, etc.) +- Derived values (`is_locked`, `is_valid`) +- Multiple rules for same trigger with different `requires` (login success vs failure) +- Temporal triggers with guards (`when: token: PasswordResetToken.expires_at <= now` with `requires: status = pending`) +- Projections for filtered collections (`pending_reset_tokens`) +- Bulk updates with `for` iteration +- Explicit `let` binding for created entities +- Black box functions (`hash()`, `verify()`) +- Surfaces with `facing` declaration and `for` iteration in `provides` + +--- + +## Pattern 2: Role-Based Access Control (RBAC) + +**Demonstrates:** Derived permissions, relationships, using permissions in `requires` clauses, surfaces + +This pattern implements hierarchical roles where higher roles inherit permissions from lower ones. + +``` +-- allium: 3 +-- rbac.allium + +------------------------------------------------------------ +-- Entities +------------------------------------------------------------ + +entity Role { + name: String -- e.g., "viewer", "editor", "admin" + permissions: Set -- e.g., { "documents.read", "documents.write" } + inherits_from: Role? -- optional parent role + + -- Derived: all permissions including inherited + effective_permissions: + permissions + (inherits_from?.effective_permissions ?? {}) +} + +entity User { + email: String + name: String +} + +entity Workspace { + name: String + owner: User + + -- Relationships + memberships: WorkspaceMembership with workspace = this + documents: Document with workspace = this + + -- Projections + members: memberships -> user + admins: memberships where role.name = "admin" -> user +} + +entity Document { + workspace: Workspace + created_by: User + title: String + content: String +} + +entity DocumentView { + user: User + document: Document + at: Timestamp +} + +-- Join entity connecting User, Workspace, and Role +entity WorkspaceMembership { + user: User + workspace: Workspace + role: Role + joined_at: Timestamp + + -- Derived: check specific permissions + can_read: "documents.read" in role.effective_permissions + can_write: "documents.write" in role.effective_permissions + can_admin: "workspace.admin" in role.effective_permissions +} + +------------------------------------------------------------ +-- Defaults +------------------------------------------------------------ + +default Role viewer = { + name: "viewer", + permissions: { "documents.read" } +} + +default Role editor = { + name: "editor", + permissions: { "documents.write" }, + inherits_from: viewer +} + +default Role admin = { + name: "admin", + permissions: { "workspace.admin", "members.manage" }, + inherits_from: editor +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +rule CreateWorkspace { + when: UserCreatesWorkspace(user, name) + + ensures: + let workspace = Workspace.created( + name: name, + owner: user + ) + -- Owner automatically becomes admin + WorkspaceMembership.created( + user: user, + workspace: workspace, + role: admin, + joined_at: now + ) +} + +rule AddMember { + when: AddMemberToWorkspace(actor, workspace, new_user, role) + + let actor_membership = WorkspaceMembership{user: actor, workspace: workspace} + + requires: actor_membership.can_admin + requires: not exists WorkspaceMembership{user: new_user, workspace: workspace} + + ensures: WorkspaceMembership.created( + user: new_user, + workspace: workspace, + role: role, + joined_at: now + ) + ensures: Email.created( + to: new_user.email, + template: added_to_workspace, + data: { workspace: workspace, role: role } + ) +} + +rule ChangeMemberRole { + when: ChangeMemberRole(actor, workspace, target_user, new_role) + + let actor_membership = WorkspaceMembership{user: actor, workspace: workspace} + let target_membership = WorkspaceMembership{user: target_user, workspace: workspace} + + requires: actor_membership.can_admin + requires: exists target_membership + requires: target_user != workspace.owner -- can't change owner's role + + ensures: target_membership.role = new_role +} + +rule RemoveMember { + when: RemoveMemberFromWorkspace(actor, workspace, target_user) + + let actor_membership = WorkspaceMembership{user: actor, workspace: workspace} + let target_membership = WorkspaceMembership{user: target_user, workspace: workspace} + + requires: actor_membership.can_admin + requires: exists target_membership + requires: target_user != workspace.owner -- can't remove owner + + ensures: not exists target_membership +} + +rule LeaveWorkspace { + when: UserLeavesWorkspace(user, workspace) + + let membership = WorkspaceMembership{user, workspace} + + requires: exists membership + requires: user != workspace.owner -- owner can't leave + + ensures: not exists membership +} + +------------------------------------------------------------ +-- Managing permissions on roles +------------------------------------------------------------ + +rule GrantPermission { + when: GrantPermission(actor, workspace, role, permission) + + let actor_membership = WorkspaceMembership{user: actor, workspace: workspace} + + requires: actor_membership.can_admin + requires: permission not in role.effective_permissions + + ensures: role.permissions.add(permission) +} + +rule RevokePermission { + when: RevokePermission(actor, workspace, role, permission) + + let actor_membership = WorkspaceMembership{user: actor, workspace: workspace} + + requires: actor_membership.can_admin + requires: permission in role.permissions -- only direct, not inherited + + ensures: role.permissions.remove(permission) +} + +------------------------------------------------------------ +-- Using permissions in other rules +------------------------------------------------------------ + +rule CreateDocument { + when: CreateDocument(user, workspace, title, content) + + let membership = WorkspaceMembership{user, workspace} + + requires: membership.can_write + + ensures: Document.created( + workspace: workspace, + created_by: user, + title: title, + content: content + ) +} + +rule ViewDocument { + when: ViewDocument(user, document) + + let membership = WorkspaceMembership{user: user, workspace: document.workspace} + + requires: membership.can_read + + ensures: DocumentView.created(user: user, document: document, at: now) +} + +------------------------------------------------------------ +-- Actors +------------------------------------------------------------ + +actor WorkspaceAdmin { + within: Workspace + identified_by: User where WorkspaceMembership{user: this, workspace: within}.can_admin = true +} + +actor WorkspaceEditor { + within: Workspace + identified_by: User where WorkspaceMembership{user: this, workspace: within}.can_write = true +} + +actor WorkspaceViewer { + within: Workspace + identified_by: User where WorkspaceMembership{user: this, workspace: within}.can_read = true +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +surface WorkspaceMemberManagement { + facing admin: WorkspaceAdmin + + context workspace: Workspace + + exposes: + workspace.name + workspace.memberships + workspace.admins + + provides: + AddMemberToWorkspace(admin, workspace, new_user, role) + ChangeMemberRole(admin, workspace, target_user, new_role) + when target_user != workspace.owner + RemoveMemberFromWorkspace(admin, workspace, target_user) + when target_user != workspace.owner + + @guarantee OwnerProtection + -- The workspace owner's role cannot be changed or removed. +} + +surface WorkspaceDocuments { + facing member: User + + context workspace: Workspace + + let membership = WorkspaceMembership{user: member, workspace: workspace} + + exposes: + workspace.name + workspace.documents + + provides: + CreateDocument(member, workspace, title, content) + when membership.can_write + for document in workspace.documents: + ViewDocument(member, document) + when membership.can_read + + related: + WorkspaceMemberManagement(workspace) + when membership.can_admin +} +``` + +**Key language features shown:** +- Recursive derived values (`effective_permissions` includes inherited) +- Null-safe navigation (`inherits_from?.effective_permissions ?? {}`) +- Join entity lookup (`WorkspaceMembership{user: actor, workspace: workspace}`) +- Permission checks in `requires` clauses +- String set membership with `in` operator +- `.add()` and `.remove()` for set mutation in ensures clauses +- `not exists` as an outcome (removes the entity) +- Surfaces with role-based actors and permission-gated actions +- `related` clause for cross-surface navigation + +--- + +## Pattern 3: Invitation to Resource + +**Demonstrates:** Tokenised actions, permission levels, invitation lifecycle, guest vs member flows, surfaces + +This pattern handles inviting users to collaborate on resources, whether they're existing users or not. + +``` +-- allium: 3 +-- resource-invitation.allium + +config { + invitation_expiry: Duration = 7.days +} + +------------------------------------------------------------ +-- Enumerations +------------------------------------------------------------ + +enum Permission { view | edit | admin } + +------------------------------------------------------------ +-- Entities +------------------------------------------------------------ + +entity Resource { + name: String + owner: User + + -- Relationships + shares: ResourceShare with resource = this + invitations: ResourceInvitation with resource = this + + -- Projections + active_shares: shares where status = active + pending_invitations: invitations where status = pending +} + +entity ResourceShare { + resource: Resource + user: User + permission: Permission + status: active | revoked + created_at: Timestamp + + -- Derived + can_view: permission in {view, edit, admin} + can_edit: permission in {edit, admin} + can_admin: permission = admin + can_invite: permission in {edit, admin} -- editors and admins can invite +} + +entity ResourceInvitation { + resource: Resource + email: String + permission: Permission + invited_by: User + created_at: Timestamp + expires_at: Timestamp + status: pending | accepted | declined | expired | revoked + + -- Derived + is_valid: status = pending and expires_at > now +} + +------------------------------------------------------------ +-- Inviting +------------------------------------------------------------ + +rule InviteToResource { + when: InviteToResource(inviter, resource, email, permission) + + let inviter_share = ResourceShare{resource: resource, user: inviter} + let existing_invitation = ResourceInvitation{resource: resource, email: email} + + requires: inviter = resource.owner or inviter_share.can_invite + requires: permission in {view, edit} -- can't invite as admin unless owner + or (permission = admin and inviter = resource.owner) + requires: not exists ResourceShare{resource: resource, user: User{email: email}} + requires: not exists existing_invitation or not existing_invitation.is_valid + + ensures: ResourceInvitation.created( + resource: resource, + email: email, + permission: permission, + invited_by: inviter, + created_at: now, + expires_at: now + config.invitation_expiry, + status: pending + ) + ensures: Email.created( + to: email, + template: resource_invitation, + data: { + resource: resource, + inviter: inviter, + permission: permission + } + ) +} + +------------------------------------------------------------ +-- Accepting (existing user) +------------------------------------------------------------ + +rule AcceptInvitationExistingUser { + when: ExistingUserAcceptsInvitation(invitation, user) + + requires: invitation.is_valid + requires: user.email = invitation.email + + ensures: invitation.status = accepted + ensures: ResourceShare.created( + resource: invitation.resource, + user: user, + permission: invitation.permission, + status: active, + created_at: now + ) + ensures: Notification.created( + to: invitation.invited_by, + template: invitation_accepted, + data: { resource: invitation.resource, user: user } + ) +} + +------------------------------------------------------------ +-- Accepting (new user - triggers signup flow) +------------------------------------------------------------ + +rule AcceptInvitationNewUser { + when: NewUserAcceptsInvitation(invitation, email, name, password) + + requires: invitation.is_valid + requires: email = invitation.email + requires: not exists User{email: email} + + ensures: + let user = User.created( + email: email, + name: name, + password_hash: hash(password), + status: active + ) + invitation.status = accepted + ResourceShare.created( + resource: invitation.resource, + user: user, + permission: invitation.permission, + status: active, + created_at: now + ) + Notification.created( + to: invitation.invited_by, + template: invitation_accepted, + data: { resource: invitation.resource, user: user } + ) +} + +------------------------------------------------------------ +-- Declining and expiring +------------------------------------------------------------ + +rule DeclineInvitation { + when: DeclineInvitation(invitation) + + requires: invitation.is_valid + + ensures: invitation.status = declined +} + +rule InvitationExpires { + when: invitation: ResourceInvitation.expires_at <= now + + requires: invitation.status = pending + + ensures: invitation.status = expired +} + +rule RevokeInvitation { + when: RevokeInvitation(actor, invitation) + + let actor_share = ResourceShare{resource: invitation.resource, user: actor} + + requires: invitation.status = pending + requires: actor = invitation.resource.owner or actor_share.can_admin + + ensures: invitation.status = revoked +} + +------------------------------------------------------------ +-- Managing shares +------------------------------------------------------------ + +rule ChangeSharePermission { + when: ChangeSharePermission(actor, share, new_permission) + + let actor_share = ResourceShare{resource: share.resource, user: actor} + + requires: actor = share.resource.owner or actor_share.can_admin + requires: share.user != share.resource.owner -- can't change owner + requires: share.status = active + + ensures: share.permission = new_permission +} + +rule RevokeShare { + when: RevokeShare(actor, share) + + let actor_share = ResourceShare{resource: share.resource, user: actor} + + requires: actor = share.resource.owner or actor_share.can_admin + requires: share.user != share.resource.owner + requires: share.status = active + + ensures: share.status = revoked + ensures: Notification.created( + to: share.user, + template: access_revoked, + data: { resource: share.resource } + ) +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +surface ResourceSharing { + facing sharer: User + + context resource: Resource + + let share = ResourceShare{resource: resource, user: sharer} + + exposes: + resource.active_shares + resource.pending_invitations + + provides: + InviteToResource(sharer, resource, email, permission) + when sharer = resource.owner or share.can_invite + for invitation in resource.pending_invitations: + RevokeInvitation(sharer, invitation) + when sharer = resource.owner or share.can_admin + for s in resource.active_shares: + ChangeSharePermission(sharer, s, new_permission) + when sharer = resource.owner or share.can_admin + RevokeShare(sharer, s) + when sharer = resource.owner or share.can_admin + + @guarantee OwnerCannotBeRevoked + -- The resource owner's access cannot be revoked or downgraded. +} + +surface InvitationResponse { + facing recipient: User + + context invitation: ResourceInvitation where email = recipient.email + + exposes: + invitation.resource.name + invitation.permission + invitation.invited_by.name + invitation.expires_at + invitation.is_valid + + provides: + ExistingUserAcceptsInvitation(invitation, recipient) + when invitation.is_valid + DeclineInvitation(invitation) + when invitation.is_valid +} +``` + +**Key language features shown:** +- Named enum (`Permission`) shared across `ResourceShare` and `ResourceInvitation` +- Complex permission logic in `requires` +- Distinct trigger names for different parameter shapes (`ExistingUserAcceptsInvitation` vs `NewUserAcceptsInvitation`) +- Invitation lifecycle (pending → accepted/declined/expired/revoked) +- Checking existence with `exists` keyword +- Permission escalation prevention (`can't invite as admin unless owner`) +- Surfaces for both resource owner and invitation recipient boundaries +- Conditional `provides` with `for` iteration over collections + +--- + +## Pattern 4: Soft Delete & Restore + +**Demonstrates:** Simple state machines, projections that filter deleted items, retention policies + +This pattern implements soft delete where items appear deleted but can be restored within a retention period. + +``` +-- allium: 3 +-- soft-delete.allium + +config { + retention_period: Duration = 30.days +} + +------------------------------------------------------------ +-- Entities +------------------------------------------------------------ + +entity Document { + workspace: Workspace + title: String + content: String + created_by: User + created_at: Timestamp + status: active | deleted + deleted_at: Timestamp? + deleted_by: User? + + -- Derived + is_active: status = active + retention_expires_at: deleted_at + config.retention_period + can_restore: status = deleted and retention_expires_at > now +} + +-- Extend Workspace to show how projections filter +entity Workspace { + name: String + + -- Relationships + all_documents: Document with workspace = this + + -- Projections (what users typically see) + documents: all_documents where status = active + deleted_documents: all_documents where status = deleted + restorable_documents: all_documents where can_restore = true +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +rule DeleteDocument { + when: DeleteDocument(actor, document) + + let membership = WorkspaceMembership{user: actor, workspace: document.workspace} + + requires: document.status = active + requires: actor = document.created_by or membership.can_admin + + ensures: document.status = deleted + ensures: document.deleted_at = now + ensures: document.deleted_by = actor +} + +rule RestoreDocument { + when: RestoreDocument(actor, document) + + let membership = WorkspaceMembership{user: actor, workspace: document.workspace} + + requires: document.can_restore + requires: actor = document.deleted_by or membership.can_admin + + ensures: document.status = active + ensures: document.deleted_at = null + ensures: document.deleted_by = null +} + +rule PermanentlyDelete { + when: PermanentlyDelete(actor, document) + + let membership = WorkspaceMembership{user: actor, workspace: document.workspace} + + requires: document.status = deleted + requires: membership.can_admin + + ensures: not exists document -- actually removed +} + +rule RetentionExpires { + when: document: Document.retention_expires_at <= now + + requires: document.status = deleted + + ensures: not exists document +} + +------------------------------------------------------------ +-- Bulk operations +------------------------------------------------------------ + +rule EmptyTrash { + when: EmptyTrash(actor, workspace) + + let membership = WorkspaceMembership{user: actor, workspace: workspace} + + requires: membership.can_admin + + ensures: + for d in workspace.deleted_documents: + not exists d +} + +rule RestoreAll { + when: RestoreAllDeleted(actor, workspace) + + let membership = WorkspaceMembership{user: actor, workspace: workspace} + + requires: membership.can_admin + + ensures: + for d in workspace.restorable_documents: + d.status = active + d.deleted_at = null + d.deleted_by = null +} +``` + +**Key language features shown:** +- `status` field with clear lifecycle +- Nullable timestamps (`deleted_at: Timestamp?`) +- Projections filtering by status (`documents: all_documents where status = active`) +- Derived values using config (`retention_expires_at: deleted_at + config.retention_period`) +- Temporal trigger for automatic cleanup (`when: document: Document.retention_expires_at <= now`) +- `not exists` for permanent removal, as distinct from soft delete +- Bulk operations with `for` iteration + +--- + +## Pattern 5: Notification Preferences & Digests + +**Demonstrates:** Sum types for notification variants, user preferences affecting rule behaviour, digest batching, temporal triggers, surfaces + +This pattern handles in-app notifications with user-controlled email preferences and digest batching. It uses sum types to model different notification kinds, each carrying its own contextual data rather than pre-computed strings. + +``` +-- allium: 3 +-- notifications.allium +-- Elided types: Comment, Resource, Task, Permission, DayOfWeek +-- (defined in other patterns or your domain spec) + +config { + digest_window: Duration = 24.hours +} + +------------------------------------------------------------ +-- Enumerations +------------------------------------------------------------ + +enum EmailFrequency { immediately | daily_digest | never } + +------------------------------------------------------------ +-- Entities +------------------------------------------------------------ + +entity User { + email: String + name: String + next_digest_at: Timestamp? + + -- Relationships + notification_setting: NotificationSetting with user = this + notifications: Notification with user = this + + -- Projections + unread_notifications: notifications where status = unread + pending_email_notifications: notifications where email_status = pending + recent_pending_notifications: notifications where email_status = pending and created_at >= now - config.digest_window +} + +entity NotificationSetting { + user: User + + -- Per-type email preferences + email_on_mention: EmailFrequency + email_on_comment: EmailFrequency + email_on_share: EmailFrequency + email_on_assignment: EmailFrequency + + -- Global settings + digest_enabled: Boolean + digest_day_of_week: Set -- domain type; define as enum in your spec +} + +------------------------------------------------------------ +-- Notification Sum Type +------------------------------------------------------------ + +-- Base notification entity with shared fields +entity Notification { + user: User + created_at: Timestamp + status: unread | read | archived + email_status: pending | sent | skipped | digested + kind: MentionNotification | ReplyNotification | ShareNotification | + AssignmentNotification | SystemNotification + + -- Derived + is_unread: status = unread +} + +-- Someone @mentioned the user in a comment +variant MentionNotification : Notification { + comment: Comment + mentioned_by: User +} + +-- Someone replied to the user's comment +variant ReplyNotification : Notification { + reply: Comment -- the new reply + original_comment: Comment -- the user's comment being replied to + replied_by: User +} + +-- Someone shared a resource with the user +variant ShareNotification : Notification { + resource: Resource + shared_by: User + permission: Permission +} + +-- Someone assigned a task to the user +variant AssignmentNotification : Notification { + task: Task + assigned_by: User +} + +-- System-generated notification (catch-all for non-structured notifications) +variant SystemNotification : Notification { + title: String + body: String + link: String? +} + +------------------------------------------------------------ +-- Supporting Entities +------------------------------------------------------------ + +entity DigestBatch { + user: User + notifications: Set + created_at: Timestamp + sent_at: Timestamp? + status: pending | sent | failed +} + +------------------------------------------------------------ +-- Creating notifications (type-specific rules) +------------------------------------------------------------ + +rule CreateMentionNotification { + when: UserMentioned(user, comment, mentioned_by) + + let settings = user.notification_setting + + requires: user != mentioned_by -- don't notify self + + ensures: MentionNotification.created( + user: user, + comment: comment, + mentioned_by: mentioned_by, + created_at: now, + status: unread, + email_status: if settings.email_on_mention = never: skipped else: pending + ) +} + +rule CreateReplyNotification { + when: CommentReplied(original_author, reply, original_comment) + + let settings = original_author.notification_setting + + requires: original_author != reply.author -- don't notify self + + ensures: ReplyNotification.created( + user: original_author, + reply: reply, + original_comment: original_comment, + replied_by: reply.author, + created_at: now, + status: unread, + email_status: if settings.email_on_comment = never: skipped else: pending + ) +} + +rule CreateShareNotification { + when: ResourceShared(user, resource, shared_by, permission) + + let settings = user.notification_setting + + requires: user != shared_by -- don't notify self + + ensures: ShareNotification.created( + user: user, + resource: resource, + shared_by: shared_by, + permission: permission, + created_at: now, + status: unread, + email_status: if settings.email_on_share = never: skipped else: pending + ) +} + +rule CreateAssignmentNotification { + when: TaskAssigned(user, task, assigned_by) + + let settings = user.notification_setting + + requires: user != assigned_by -- don't notify self + + ensures: AssignmentNotification.created( + user: user, + task: task, + assigned_by: assigned_by, + created_at: now, + status: unread, + email_status: if settings.email_on_assignment = never: skipped else: pending + ) +} + +rule CreateSystemNotification { + when: SystemNotificationTriggered(user, title, body, link) + + ensures: SystemNotification.created( + user: user, + title: title, + body: body, + link: link, + created_at: now, + status: unread, + email_status: pending + ) +} + +------------------------------------------------------------ +-- Immediate email sending +------------------------------------------------------------ + +rule SendImmediateEmail { + when: notification: Notification.created + + let settings = notification.user.notification_setting + let preference = + if notification.kind = MentionNotification: settings.email_on_mention + else if notification.kind = ReplyNotification: settings.email_on_comment + else if notification.kind = ShareNotification: settings.email_on_share + else if notification.kind = AssignmentNotification: settings.email_on_assignment + else: immediately -- system notifications send immediately by default + + requires: notification.email_status = pending + requires: preference = immediately + + ensures: Email.created( + to: notification.user.email, + template: notification_immediate, + data: { notification: notification } + ) + ensures: notification.email_status = sent +} + +------------------------------------------------------------ +-- Reading notifications +------------------------------------------------------------ + +rule MarkAsRead { + when: MarkNotificationRead(user, notification) + + requires: notification.user = user + requires: notification.status = unread + + ensures: notification.status = read +} + +rule MarkAllAsRead { + when: MarkAllNotificationsRead(user) + + ensures: + for n in user.unread_notifications: + n.status = read +} + +rule ArchiveNotification { + when: ArchiveNotification(user, notification) + + requires: notification.user = user + + ensures: notification.status = archived +} + +------------------------------------------------------------ +-- Daily digest +------------------------------------------------------------ + +rule CreateDailyDigest { + when: user: User.next_digest_at <= now + + requires: user.notification_setting.digest_enabled + + let pending = user.recent_pending_notifications + + requires: pending.count > 0 + + ensures: DigestBatch.created( + user: user, + notifications: pending, + created_at: now, + status: pending + ) + ensures: + for n in pending: + n.email_status = digested + ensures: user.next_digest_at = next_digest_time(user) -- black box; uses digest_day_of_week +} + +rule SendDigest { + when: batch: DigestBatch.created + + requires: batch.status = pending + requires: batch.notifications.count > 0 + + ensures: Email.created( + to: batch.user.email, + template: daily_digest, + data: { + notifications: batch.notifications, + unread_count: batch.user.unread_notifications.count + } + ) + ensures: batch.status = sent + ensures: batch.sent_at = now +} + +------------------------------------------------------------ +-- Preference updates +------------------------------------------------------------ + +rule UpdateNotificationPreferences { + when: UpdatePreferences(user, preferences) + + let settings = user.notification_setting + + ensures: settings.email_on_mention = preferences.mention + ensures: settings.email_on_comment = preferences.comment + ensures: settings.email_on_share = preferences.share + ensures: settings.email_on_assignment = preferences.assignment + ensures: settings.digest_enabled = preferences.digest_enabled + ensures: settings.digest_day_of_week = preferences.digest_days +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +surface NotificationCentre { + facing user: User + + exposes: + user.unread_notifications + user.unread_notifications.count + user.notifications + + provides: + for notification in user.unread_notifications: + MarkNotificationRead(user, notification) + MarkAllNotificationsRead(user) + when user.unread_notifications.count > 0 + for notification in user.notifications: + ArchiveNotification(user, notification) + + related: + NotificationPreferences(user) +} + +surface NotificationPreferences { + facing user: User + + context settings: NotificationSetting where user = user + + exposes: + settings.email_on_mention + settings.email_on_comment + settings.email_on_share + settings.email_on_assignment + settings.digest_enabled + settings.digest_day_of_week + + provides: + UpdatePreferences(user, preferences) +} +``` + +**Key language features shown:** +- **Sum types**: `kind: MentionNotification | ReplyNotification | ...` declares notification variants +- **Variant declarations**: Each notification kind uses `variant X : Notification` syntax +- **Variant-specific creation rules**: Each variant has its own creation rule with appropriate fields +- **Exhaustive kind checking**: `SendImmediateEmail` handles all variants explicitly +- Named enum (`EmailFrequency`) shared across preference fields +- User preferences stored as entity +- Temporal trigger for per-user digest scheduling (`when: user: User.next_digest_at <= now`) +- Digest batching with temporal trigger +- Surfaces with `related` clause linking notification centre to preferences + +**Why sum types here?** + +The previous approach used pre-computed `title`, `body`, and `link` strings: +``` +Notification.created( + type: mention, + title: "{author} mentioned you", + body: truncate(comment.body, 100), + link: comment.parent.url +) +``` + +With sum types, each notification carries its actual entity references: +``` +MentionNotification.created( + comment: comment, + mentioned_by: author +) +``` + +This is better because: +1. **Rich queries**: "Show all notifications about this document" queries the actual relationships +2. **Type safety**: Creating a `MentionNotification` requires a `comment` - you can't forget it +3. **Flexible rendering**: Display logic can access full entity data, not just truncated strings +4. **Consistency**: If a user's name changes, notification titles reflect the current name + +--- + +## Pattern 6: Usage Limits & Quotas + +**Demonstrates:** Limit checks in `requires`, metered resources, plan tiers, overage handling, surfaces + +This pattern handles SaaS usage limits: different plans have different quotas, and usage is tracked and enforced. + +``` +-- allium: 3 +-- usage-limits.allium +-- Elided types: Feature (define as enum in your spec) + +------------------------------------------------------------ +-- Entities +------------------------------------------------------------ + +entity Plan { + name: String -- e.g., "free", "pro", "enterprise" + + -- Limits (null = unlimited) + max_documents: Integer? + max_storage_bytes: Integer? + max_team_members: Integer? + max_api_requests_per_day: Integer? + + -- Features + features: Set -- domain type; define in your spec + + -- Derived + has_unlimited_documents: max_documents = null + has_unlimited_storage: max_storage_bytes = null + has_unlimited_members: max_team_members = null +} + +entity Workspace { + name: String + owner: User + plan: Plan + api_key: String? + + -- Relationships + documents: Document with workspace = this + memberships: WorkspaceMembership with workspace = this + usage: WorkspaceUsage with workspace = this + + -- Derived checks + can_add_document: plan.has_unlimited_documents or documents.count < plan.max_documents + can_add_member: plan.has_unlimited_members or memberships.count < plan.max_team_members + can_use_feature(f): f in plan.features +} + +entity WorkspaceUsage { + workspace: Workspace + storage_bytes_used: Integer + api_requests_today: Integer + next_reset_at: Timestamp + + -- Derived (null when plan has no limit) + api_requests_remaining: + workspace.plan.max_api_requests_per_day - api_requests_today + has_api_quota: workspace.plan.max_api_requests_per_day != null + is_over_api_quota: has_api_quota and api_requests_remaining <= 0 +} + +entity UsageEvent { + workspace: Workspace + type: document_created | document_deleted | storage_added | + storage_removed | api_request | member_added | member_removed + amount: Integer + recorded_at: Timestamp +} + +------------------------------------------------------------ +-- Defaults +------------------------------------------------------------ + +default Plan free = { + name: "free", + max_documents: 10, + max_storage_bytes: 100_000_000, -- 100MB + max_team_members: 3, + max_api_requests_per_day: 100, + features: { basic_editing } +} + +default Plan pro = { + name: "pro", + max_documents: 1000, + max_storage_bytes: 10_000_000_000, -- 10GB + max_team_members: 20, + max_api_requests_per_day: 10000, + features: { basic_editing, advanced_editing, api_access, integrations } +} + +default Plan enterprise = { + name: "enterprise", + features: { basic_editing, advanced_editing, api_access, integrations, + sso, audit_log, custom_branding } + -- max_documents, max_storage_bytes, max_team_members, + -- max_api_requests_per_day all null (unlimited) +} + +------------------------------------------------------------ +-- Enforcing limits +------------------------------------------------------------ + +rule CreateDocument { + when: CreateDocument(user, workspace, title) + + requires: workspace.can_add_document + + ensures: Document.created(workspace: workspace, title: title, created_by: user) + ensures: UsageEvent.created( + workspace: workspace, + type: document_created, + amount: 1, + recorded_at: now + ) +} + +rule CreateDocumentLimitReached { + when: CreateDocument(user, workspace, title) + + requires: not workspace.can_add_document + + ensures: UserInformed( + user: user, + about: limit_reached, + data: { + limit_type: documents, + current: workspace.documents.count, + max: workspace.plan.max_documents, + upgrade_path: next_plan(workspace.plan) + } + ) +} + +rule AddTeamMember { + when: AddMember(actor, workspace, new_member, role) + + requires: workspace.can_add_member + requires: WorkspaceMembership{user: actor, workspace: workspace}.can_admin + + ensures: WorkspaceMembership.created(...) + ensures: UsageEvent.created( + workspace: workspace, + type: member_added, + amount: 1, + recorded_at: now + ) +} + +rule UseFeature { + when: UseFeature(user, workspace, feature) + + requires: workspace.can_use_feature(feature) + + ensures: FeatureUsed(workspace: workspace, feature: feature, by: user) +} + +rule UseFeatureNotAvailable { + when: UseFeature(user, workspace, feature) + + requires: not workspace.can_use_feature(feature) + + ensures: UserInformed( + user: user, + about: feature_not_available, + data: { + feature: feature, + available_on: plans_with_feature(feature) + } + ) +} + +------------------------------------------------------------ +-- API rate limiting +------------------------------------------------------------ + +rule RecordApiRequest { + when: ApiRequestReceived(workspace, endpoint) + + let usage = workspace.usage + + requires: not usage.is_over_api_quota + + ensures: usage.api_requests_today = usage.api_requests_today + 1 + ensures: UsageEvent.created( + workspace: workspace, + type: api_request, + amount: 1, + recorded_at: now + ) +} + +rule ApiRateLimitExceeded { + when: ApiRequestReceived(workspace, endpoint) + + let usage = workspace.usage + + requires: usage.is_over_api_quota + + ensures: ApiRequestRejected( + workspace: workspace, + reason: rate_limit_exceeded, + data: { resets_at: usage.next_reset_at } + ) +} + +rule ResetDailyApiUsage { + when: usage: WorkspaceUsage.next_reset_at <= now + + requires: usage.api_requests_today > 0 -- prevents re-firing when already reset + + ensures: usage.api_requests_today = 0 + ensures: usage.next_reset_at = usage.next_reset_at + 1.day +} + +------------------------------------------------------------ +-- Plan changes +------------------------------------------------------------ + +rule UpgradePlan { + when: UpgradePlan(workspace, new_plan) + + let old_plan = workspace.plan + + requires: new_plan.max_documents >= old_plan.max_documents + or new_plan.has_unlimited_documents + + ensures: workspace.plan = new_plan + ensures: Email.created( + to: workspace.owner.email, + template: plan_upgraded, + data: { old_plan: old_plan, new_plan: new_plan } + ) +} + +rule DowngradePlan { + when: DowngradePlan(workspace, new_plan) + + let old_plan = workspace.plan + + -- Can only downgrade if under new plan's limits + requires: workspace.documents.count <= new_plan.max_documents + or new_plan.has_unlimited_documents + requires: workspace.memberships.count <= new_plan.max_team_members + or new_plan.has_unlimited_members + requires: workspace.usage.storage_bytes_used <= new_plan.max_storage_bytes + or new_plan.has_unlimited_storage + + ensures: workspace.plan = new_plan + ensures: Email.created( + to: workspace.owner.email, + template: plan_downgraded, + data: { old_plan: old_plan, new_plan: new_plan } + ) +} + +rule DowngradeBlocked { + when: DowngradePlan(workspace, new_plan) + + let over_documents = + workspace.documents.count > new_plan.max_documents + and not new_plan.has_unlimited_documents + let over_members = + workspace.memberships.count > new_plan.max_team_members + and not new_plan.has_unlimited_members + let over_storage = + workspace.usage.storage_bytes_used > new_plan.max_storage_bytes + and not new_plan.has_unlimited_storage + + requires: over_documents or over_members or over_storage + + ensures: UserInformed( + user: workspace.owner, + about: downgrade_blocked, + data: { + over_documents: over_documents, + over_members: over_members, + over_storage: over_storage + } + ) +} + +------------------------------------------------------------ +-- Actors +------------------------------------------------------------ + +actor WorkspaceOwner { + within: Workspace + identified_by: User where this = within.owner +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +surface UsageDashboard { + facing owner: WorkspaceOwner + + context workspace: Workspace + + exposes: + workspace.plan + workspace.documents.count + workspace.plan.max_documents + workspace.memberships.count + workspace.plan.max_team_members + workspace.usage.storage_bytes_used + workspace.plan.max_storage_bytes + workspace.usage.api_requests_today + workspace.usage.api_requests_remaining + + provides: + UpgradePlan(workspace, new_plan) + DowngradePlan(workspace, new_plan) + + @guidance + -- Show progress bars for usage against limits. + -- Highlight when any resource is above 80% of its limit. +} + +surface APIAccess { + facing consumer: Workspace + + exposes: + consumer.usage.api_requests_remaining + consumer.plan.max_api_requests_per_day + + provides: + ApiRequestReceived(consumer, endpoint) + when not consumer.usage.is_over_api_quota + + @guarantee RateLimitEnforcement + -- Requests beyond the daily limit receive HTTP 429 with + -- reset time. +} +``` + +**Key language features shown:** +- Plan definitions with limits +- Derived boolean checks for limit enforcement (`can_add_document`, `can_add_member`) +- `requires` checking limits before actions +- Paired rules for success/failure cases +- Usage tracking with events +- Temporal trigger for daily reset (`when: usage: WorkspaceUsage.next_reset_at <= now`) +- Plan upgrade/downgrade logic with `let` binding to capture pre-mutation state +- Feature flags (`can_use_feature(f)`) +- Interaction surface for usage dashboard and API surface with rate limit guarantee + +--- + +## Pattern 7: Comments with Mentions + +**Demonstrates:** Nested entities, parsing for mentions, cross-entity notifications, threading, surfaces + +This pattern implements comments with @mentions, including mention parsing and notification generation. + +``` +-- allium: 3 +-- comments.allium + +------------------------------------------------------------ +-- Entities +------------------------------------------------------------ + +external entity User { + name: String + is_admin: Boolean +} + +external entity Commentable { + -- Defined by the consuming spec (e.g., Document, Task, Project) +} + +entity Comment { + parent: Commentable + reply_to: Comment? -- null for top-level, set for replies + author: User + body: String + created_at: Timestamp + edited_at: Timestamp? + status: active | deleted + + -- Relationships + mentions: CommentMention with comment = this + replies: Comment with reply_to = this + reactions: CommentReaction with comment = this + + -- Projections + active_replies: replies where status = active + + -- Derived + is_reply: reply_to != null + is_edited: edited_at != null + mentioned_users: mentions -> user + thread_depth: if is_reply: reply_to.thread_depth + 1 else: 0 +} + +-- Join entity for mentions +entity CommentMention { + comment: Comment + user: User + notified: Boolean +} + +entity CommentReaction { + comment: Comment + user: User + emoji: String -- e.g., "👍", "❤️", "🎉" + created_at: Timestamp +} + +------------------------------------------------------------ +-- Creating comments +------------------------------------------------------------ + +rule CreateComment { + when: CreateComment(author, parent, body) + + let mentioned_usernames = parse_mentions(body) -- black box: extracts @username + let mentioned_users = users_with_usernames(mentioned_usernames) -- black box lookup + + ensures: + let comment = Comment.created( + parent: parent, + reply_to: null, + author: author, + body: body, + created_at: now, + status: active + ) + for user in mentioned_users: + CommentMention.created( + comment: comment, + user: user, + notified: false + ) +} + +rule CreateReply { + when: CreateReply(author, parent_comment, body) + + let mentioned_usernames = parse_mentions(body) + let mentioned_users = users_with_usernames(mentioned_usernames) -- black box lookup + + requires: parent_comment.status = active + requires: parent_comment.thread_depth < 3 -- limit nesting + + ensures: + let comment = Comment.created( + parent: parent_comment.parent, + reply_to: parent_comment, + author: author, + body: body, + created_at: now, + status: active + ) + for user in mentioned_users: + CommentMention.created( + comment: comment, + user: user, + notified: false + ) +} + +------------------------------------------------------------ +-- Notifications for mentions and replies +------------------------------------------------------------ + +-- Trigger the notification system when someone is mentioned +rule NotifyMentionedUser { + when: mention: CommentMention.created + + requires: mention.user != mention.comment.author -- don't notify self + requires: not mention.notified + + ensures: mention.notified = true + ensures: UserMentioned( + user: mention.user, + comment: mention.comment, + mentioned_by: mention.comment.author + ) +} + +-- Trigger the notification system when someone's comment receives a reply +rule NotifyCommentAuthorOfReply { + when: comment: Comment.created + + let original_author = comment.reply_to?.author + + requires: comment.is_reply + requires: original_author != null + requires: original_author != comment.author -- don't notify self + requires: original_author not in comment.mentioned_users -- avoid double notify + + ensures: CommentReplied( + original_author: original_author, + reply: comment, + original_comment: comment.reply_to + ) +} + +------------------------------------------------------------ +-- Editing +------------------------------------------------------------ + +rule EditComment { + when: EditComment(actor, comment, new_body) + + requires: actor = comment.author + requires: comment.status = active + + let old_mentions = comment.mentioned_users + let new_mentioned_usernames = parse_mentions(new_body) + let new_mentioned_users = users_with_usernames(new_mentioned_usernames) -- black box lookup + let added_mentions = new_mentioned_users - old_mentions + let removed_mentions = old_mentions - new_mentioned_users + + ensures: comment.body = new_body + ensures: comment.edited_at = now + + -- Remove old mentions that are no longer present + ensures: + for user in removed_mentions: + not exists CommentMention{comment, user} + + -- Add new mentions + ensures: + for user in added_mentions: + CommentMention.created( + comment: comment, + user: user, + notified: false + ) +} + +------------------------------------------------------------ +-- Deleting +------------------------------------------------------------ + +rule DeleteComment { + when: DeleteComment(actor, comment) + + requires: actor = comment.author or actor.is_admin + requires: comment.status = active + + ensures: comment.status = deleted + -- Note: replies remain but show "deleted comment" +} + +------------------------------------------------------------ +-- Reactions +------------------------------------------------------------ + +rule AddReaction { + when: AddReaction(user, comment, emoji) + + requires: comment.status = active + requires: not exists CommentReaction{comment, user, emoji} + + ensures: CommentReaction.created( + comment: comment, + user: user, + emoji: emoji, + created_at: now + ) +} + +rule RemoveReaction { + when: RemoveReaction(user, comment, emoji) + + let reaction = CommentReaction{comment, user, emoji} + + requires: comment.status = active + requires: exists reaction + + ensures: not exists reaction +} + +rule ToggleReaction { + when: ToggleReaction(user, comment, emoji) + + let existing = CommentReaction{comment, user, emoji} + + requires: comment.status = active + + ensures: + if exists existing: + not exists existing + else: + CommentReaction.created( + comment: comment, + user: user, + emoji: emoji, + created_at: now + ) +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +surface CommentThread { + facing viewer: User + + context parent: Commentable + + let comments = Comments where parent = parent and status = active + + exposes: + for comment in comments: + comment.author.name + comment.body + comment.created_at + comment.is_edited + comment.active_replies + comment.reactions + + provides: + CreateComment(viewer, parent, body) + for comment in comments: + CreateReply(viewer, comment, body) + when comment.thread_depth < 3 + EditComment(viewer, comment, new_body) + when viewer = comment.author + DeleteComment(viewer, comment) + when viewer = comment.author or viewer.is_admin + AddReaction(viewer, comment, emoji) + RemoveReaction(viewer, comment, emoji) + when exists CommentReaction{comment: comment, user: viewer, emoji: emoji} + + @guidance + -- Show "edited" indicator when comment.is_edited. + -- Show "deleted comment" placeholder for deleted replies + -- rather than removing them from the thread. +} +``` + +**Key language features shown:** +- Nested/recursive entities (comments with replies) +- Entity creation triggers with binding (`when: mention: CommentMention.created`) +- Black box functions (`parse_mentions()`, `users_with_usernames()`) +- Explicit `let` binding for created entities +- Set operations (`new_mentioned_users - old_mentions`) +- Depth limiting (`thread_depth < 3`) +- **Cross-pattern triggers**: Emits `UserMentioned` and `CommentReplied` triggers that Pattern 5 handles +- Avoiding double notifications (`original_author not in comment.mentioned_users`) +- Toggle pattern with conditional ensures +- Join entity with three keys (`CommentReaction{comment, user, emoji}`) +- Surface with role-conditional actions (author can edit, author or admin can delete) + +--- + +## Pattern 8: Integrating Library Specs + +**Demonstrates:** External spec references with coordinates, configuration blocks, config parameter references, responding to external triggers, using external entities + +Library specs are standalone specifications for common functionality: authentication providers, payment processors, email services. They define a contract that implementations must satisfy, and your application spec composes them in. Consuming specs can reference a library spec's config values as defaults for their own parameters, avoiding duplication when the values should track each other. + +### Example: OAuth Authentication + +This example shows integrating a library OAuth spec into your application. The OAuth spec handles the authentication flow; your application responds to authentication events and manages application-level user state. + +``` +-- allium: 3 +-- app-auth.allium + +------------------------------------------------------------ +-- External Spec References +------------------------------------------------------------ + +-- Reference the OAuth spec from the library +-- The coordinate is immutable (git SHA), ensuring reproducible specs +use "github.com/allium-specs/oauth2/af8e2c1d" as oauth + +-- Configure the OAuth spec for our application +oauth/config { + providers: { google, microsoft, github } + session_duration: 24.hours + refresh_window: 1.hour + link_expiry: 15.minutes +} + +------------------------------------------------------------ +-- Application Entities +------------------------------------------------------------ + +-- Our application's User entity, linked to OAuth identities +entity User { + email: String + name: String + avatar_url: String? + status: active | suspended | deactivated + created_at: Timestamp + last_login_at: Timestamp? + + -- Relationship to OAuth sessions (from external spec) + sessions: oauth/Session with user = this + identities: oauth/Identity with user = this + + -- Projections + active_sessions: sessions where status = active + + -- Derived + is_authenticated: active_sessions.count > 0 + linked_providers: identities -> provider +} + +-- Application-specific user preferences +entity UserPreferences { + user: User + theme: light | dark | system + timezone: String + locale: String +} + +------------------------------------------------------------ +-- Responding to OAuth Events +------------------------------------------------------------ + +-- When a user authenticates for the first time, create our User entity +rule CreateUserOnFirstLogin { + when: oauth/AuthenticationSucceeded(identity, session) + + requires: not exists User{email: identity.email} + + ensures: + let user = User.created( + email: identity.email, + name: identity.display_name, + avatar_url: identity.avatar_url, + status: active, + created_at: now, + last_login_at: now + ) + -- Link the OAuth identity to our user + identity.user = user + session.user = user + -- Create default preferences + UserPreferences.created( + user: user, + theme: system, + timezone: identity.timezone ?? "UTC", + locale: identity.locale ?? "en" + ) + Email.created( + to: user.email, + template: welcome, + data: { user: user, provider: identity.provider } + ) +} + +-- When an existing user logs in, update last login +rule UpdateUserOnLogin { + when: oauth/AuthenticationSucceeded(identity, session) + + let user = User{email: identity.email} + + requires: exists user + requires: user.status = active + + ensures: user.last_login_at = now + ensures: session.user = user +} + +-- Block login for suspended users +rule BlockSuspendedUserLogin { + when: oauth/AuthenticationSucceeded(identity, session) + + let user = User{email: identity.email} + + requires: exists user + requires: user.status = suspended + + ensures: session.status = revoked + ensures: UserInformed( + user: user, + about: account_suspended, + data: { contact: "support@example.com" } + ) +} + +-- When OAuth session expires, we might want to notify +rule NotifySessionExpiring { + when: session: oauth/Session.status transitions_to expiring + + let user = session.user + + requires: user != null + + ensures: UserInformed( + user: user, + about: session_expiring, + data: { time_remaining: session.time_remaining } + ) +} + +-- Audit logging for security events +rule AuditLogout { + when: oauth/SessionTerminated(session, reason) + + let user = session.user + + requires: user != null + + ensures: AuditLog.created( + user: user, + event: logout, + reason: reason, + timestamp: now, + metadata: { provider: session.provider, session_start: session.created_at } + ) +} + +------------------------------------------------------------ +-- Application Actions Using OAuth +------------------------------------------------------------ + +rule LinkAdditionalProvider { + when: LinkProvider(user, provider) + + requires: user.status = active + requires: provider not in user.linked_providers + + -- Trigger the OAuth flow from the library spec + ensures: oauth/InitiateAuthentication( + provider: provider, + intent: link_account, + existing_user: user + ) +} + +rule UnlinkProvider { + when: UnlinkProvider(user, provider) + + let identity = oauth/Identity{user, provider} + + requires: user.status = active + requires: exists identity + requires: user.linked_providers.count > 1 -- must keep at least one + + ensures: not exists identity + ensures: AuditLog.created( + user: user, + event: provider_unlinked, + timestamp: now, + metadata: { provider: provider } + ) +} +``` + +### Example: Payment Processing + +This example shows integrating a payment processor spec for subscription billing. + +``` +-- allium: 3 +-- billing.allium + +------------------------------------------------------------ +-- External Spec References +------------------------------------------------------------ + +use "github.com/allium-specs/stripe-billing/b2c4e6f8" as stripe + +stripe/config { + currency: USD + tax_calculation: automatic + proration: create_prorations + trial_period: 14.days +} + +config { + trial_period: Duration = stripe/config.trial_period + extended_trial: Duration = stripe/config.trial_period * 2 + trial_reminder_lead: Duration = 3.days +} + +------------------------------------------------------------ +-- Application Entities +------------------------------------------------------------ + +entity Organisation { + name: String + owner: User + billing_portal_url: String? + + -- Link to Stripe customer (from external spec) + stripe_customer: stripe/Customer? + + -- Relationships + subscription: Subscription with organisation = this + invoices: stripe/Invoice with stripe_customer = this + + -- Derived + is_paying: subscription?.status = active + has_payment_method: stripe_customer?.default_payment_method != null +} + +entity Subscription { + organisation: Organisation + plan: Plan + status: trialing | active | past_due | cancelled | expired + started_at: Timestamp + trial_ends_at: Timestamp? + current_period_ends_at: Timestamp + trial_reminder_sent: Boolean + + -- Link to Stripe subscription + stripe_subscription: stripe/Subscription? + + -- Derived + is_trial: status = trialing + days_until_renewal: current_period_ends_at - now +} + +------------------------------------------------------------ +-- Responding to Payment Events +------------------------------------------------------------ + +-- When Stripe confirms payment, activate or renew subscription +rule ActivateOnPaymentSuccess { + when: stripe/PaymentSucceeded(invoice) + + let customer = invoice.customer + let org = Organisation{stripe_customer: customer} + let sub = org.subscription + + requires: exists org + requires: sub.status in {trialing, past_due} + + ensures: sub.status = active + ensures: sub.current_period_ends_at = invoice.period_end + ensures: Email.created( + to: org.owner.email, + template: payment_confirmed, + data: { amount: invoice.amount, next_billing: invoice.period_end } + ) +} + +-- Handle failed payments +rule HandlePaymentFailure { + when: stripe/PaymentFailed(invoice, failure_reason) + + let customer = invoice.customer + let org = Organisation{stripe_customer: customer} + let sub = org.subscription + + requires: exists org + + ensures: sub.status = past_due + ensures: Email.created( + to: org.owner.email, + template: payment_failed, + data: { + reason: failure_reason, + retry_date: invoice.next_payment_attempt, + update_payment_url: org.billing_portal_url + } + ) + ensures: UserInformed( + user: org.owner, + about: payment_failed, + data: { reason: failure_reason } + ) +} + +-- When trial is ending, remind user +rule TrialEndingReminder { + when: sub: Subscription.trial_ends_at - config.trial_reminder_lead <= now + + requires: sub.status = trialing + requires: not sub.trial_reminder_sent + + let org = sub.organisation + + ensures: sub.trial_reminder_sent = true + ensures: Email.created( + to: org.owner.email, + template: trial_ending, + data: { + days_remaining: config.trial_reminder_lead, + plan: sub.plan, + has_payment_method: org.has_payment_method + } + ) +} + +-- Respond to subscription cancellation from Stripe +rule HandleSubscriptionCancelled { + when: stripe/SubscriptionCancelled(stripe_sub, reason) + + let sub = Subscription{stripe_subscription: stripe_sub} + let org = sub.organisation + + requires: exists sub + + ensures: sub.status = cancelled + ensures: Email.created( + to: org.owner.email, + template: subscription_cancelled, + data: { reason: reason, access_until: sub.current_period_ends_at } + ) + ensures: AuditLog.created( + user: org.owner, + event: subscription_cancelled, + timestamp: now, + metadata: { reason: reason, plan: sub.plan.name } + ) +} + +------------------------------------------------------------ +-- Application Actions Using Stripe +------------------------------------------------------------ + +rule StartSubscription { + when: StartSubscription(org, plan) + + requires: org.subscription = null or org.subscription.status in {cancelled, expired} + requires: org.stripe_customer != null + requires: org.has_payment_method + + ensures: stripe/CreateSubscription( + customer: org.stripe_customer, + price: plan.stripe_price_id, + trial_period: if plan.has_trial: stripe/config.trial_period else: null + ) +} + +rule ChangePlan { + when: ChangePlan(org, new_plan) + + let sub = org.subscription + + requires: sub.status = active + requires: new_plan != sub.plan + + ensures: stripe/UpdateSubscription( + subscription: sub.stripe_subscription, + new_price: new_plan.stripe_price_id + ) + ensures: sub.plan = new_plan +} + +rule CancelSubscription { + when: CancelSubscription(org, reason) + + let sub = org.subscription + + requires: sub.status in {active, trialing} + + ensures: stripe/CancelSubscription( + subscription: sub.stripe_subscription, + at_period_end: true -- access continues until paid period ends + ) + ensures: AuditLog.created( + user: org.owner, + event: cancellation_requested, + timestamp: now, + metadata: { reason: reason } + ) +} +``` + +**Key language features shown:** +- External spec references with immutable coordinates (`use "github.com/.../abc123" as alias`) +- Configuration blocks for external specs (`oauth/config { ... }`) +- Config parameter references as defaults (`trial_period: Duration = stripe/config.trial_period`) +- Expression-form defaults derived from library config (`extended_trial: Duration = stripe/config.trial_period * 2`) +- Responding to external triggers (`when: oauth/AuthenticationSucceeded(...)`) +- Trigger emissions for cross-pattern notification (`UserInformed(...)`) +- Responding to external state transitions (`when: session: oauth/Session.status transitions_to expiring`) +- Using external entities (`oauth/Session`, `stripe/Customer`) +- Linking application entities to external entities (`stripe_customer: stripe/Customer?`) +- Triggering external actions (`ensures: stripe/CreateSubscription(...)`) +- Qualified names throughout (`oauth/Session`, `stripe/config.trial_period`) + +### Library Spec Design Principles + +When creating or choosing library specs: + +1. **Immutable coordinates**: Always use content-addressed references (git SHAs), never floating versions +2. **Configuration over convention**: Library specs should expose configuration for anything that might vary between applications +3. **Observable triggers**: Library specs should emit triggers for all significant events so consuming specs can respond +4. **Minimal coupling**: Library specs shouldn't depend on your application entities - the linkage goes one way +5. **Clear boundaries**: The library spec handles its domain (OAuth flow, payment processing); your spec handles application concerns (user creation, access control) + +--- + +## Pattern 9: Framework Integration Contract + +**Demonstrates:** Contract declarations, expression-bearing invariants, `contracts:` clause with `demands`/`fulfils`, programmatic surfaces, typed signatures + +This pattern specifies the contract between an event-sourcing framework and its domain modules. The framework demands that each module supply a deterministic evaluation function; in return, the surface fulfils event submission and state snapshot services. Unlike user-facing surfaces that use `exposes` and `provides`, framework-to-module boundaries use a `contracts:` clause with `demands` and `fulfils` to describe programmatic obligations. Contracts are declared at module level so they can be reused across surfaces or referenced from other specs. + +``` +-- allium: 3 +-- event-sourcing-integration.allium + +------------------------------------------------------------ +-- Value Types +------------------------------------------------------------ + +value EntityKey { + kind: String + id: String +} + +value EventOutcome { + entity_key: EntityKey + new_state: ByteArray + side_effects: List +} + +value SideEffect { + kind: emit_event | schedule_timeout | request_snapshot + payload: ByteArray +} + +value SnapshotRequest { + entity_key: EntityKey + as_of: Timestamp +} + +value Snapshot { + entity_key: EntityKey + state: ByteArray + version: Integer + taken_at: Timestamp +} + +------------------------------------------------------------ +-- Contracts +------------------------------------------------------------ + +contract DeterministicEvaluation { + evaluate: (event_name: String, payload: ByteArray, current_state: ByteArray) -> EventOutcome + + @invariant Determinism + -- For identical inputs (event_name, payload, current_state), + -- evaluate must produce byte-identical EventOutcome values + -- across all instances and invocations. + + @invariant Purity + -- evaluate must not perform I/O, read the system clock, + -- access mutable state outside its arguments, or depend + -- on the order of previous invocations. + + @invariant TotalFunction + -- evaluate must return a valid EventOutcome for every + -- combination of registered event_name, well-formed payload + -- and current_state. It must not throw or fail to terminate. + + @guidance + -- Implementations should avoid allocating during evaluation + -- where possible, as the framework may invoke evaluate + -- at high frequency during replay. +} + +contract EventSubmitter { + submit: (idempotency_key: String, event_name: String, payload: ByteArray) -> EventSubmission + + @invariant AtMostOnceProcessing + -- Within the submission TTL window (config.submission_ttl), + -- a given idempotency key is accepted at most once. + -- Duplicate submissions are rejected. + + @invariant OrderPreservation + -- Events submitted by a single module are processed in + -- submission order. No ordering guarantee exists across + -- modules. +} + +contract StateSnapshots { + request_snapshot: (entity_key: EntityKey) -> Snapshot + get_snapshot: (request: SnapshotRequest) -> Snapshot? + + @invariant SnapshotConsistency + -- A snapshot reflects the state after applying all events + -- up to and including the snapshot's version number. + -- No partial application. +} + +------------------------------------------------------------ +-- Entities +------------------------------------------------------------ + +entity DomainModule { + name: String + version: String + status: registered | active | suspended + + -- Relationships + event_types: EventTypeRegistration with module = this + + -- Projections + active_event_types: event_types where status = active +} + +entity EventTypeRegistration { + module: DomainModule + event_name: String + schema_hash: String + status: active | deprecated + registered_at: Timestamp +} + +entity EventSubmission { + module: DomainModule + idempotency_key: String + event_name: String + payload: ByteArray + status: pending | accepted + submitted_at: Timestamp + processed_at: Timestamp? + + invariant PayloadWithinLimit { length(payload) <= config.max_payload_bytes } +} + +------------------------------------------------------------ +-- Config +------------------------------------------------------------ + +config { + submission_ttl: Duration = 24.hours + max_payload_bytes: Integer = 1_000_000 +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +rule RegisterModule { + when: RegisterModule(module_name, version, event_types) + + requires: not exists DomainModule{name: module_name} + + ensures: + let module = DomainModule.created( + name: module_name, + version: version, + status: registered + ) + for event_name in event_types: + EventTypeRegistration.created( + module: module, + event_name: event_name, + schema_hash: hash(event_name + version), + status: active, + registered_at: now + ) +} + +rule ActivateModule { + when: module: DomainModule.status becomes registered + + requires: module.event_types.count > 0 + + ensures: module.status = active +} + +rule SubmitEvent { + when: SubmitEvent(module, idempotency_key, event_name, payload) + + let existing = EventSubmission{module: module, idempotency_key: idempotency_key} + + requires: module.status = active + requires: not exists existing + requires: exists EventTypeRegistration{module: module, event_name: event_name, status: active} + requires: length(payload) <= config.max_payload_bytes + + ensures: EventSubmission.created( + module: module, + idempotency_key: idempotency_key, + event_name: event_name, + payload: payload, + status: pending, + submitted_at: now + ) +} + +rule ProcessSubmission { + when: submission: EventSubmission.status becomes pending + + ensures: submission.status = accepted + ensures: submission.processed_at = now +} + +rule ExpireOldSubmissions { + when: submission: EventSubmission.submitted_at + config.submission_ttl <= now + + requires: submission.status in {pending, accepted} + + ensures: not exists submission +} + +rule DeprecateEventType { + when: DeprecateEventType(module, event_name) + + let registration = EventTypeRegistration{module: module, event_name: event_name} + + requires: exists registration + requires: registration.status = active + + ensures: registration.status = deprecated +} + +rule SuspendModule { + when: SuspendModule(admin, module, reason) + + requires: module.status = active + + ensures: module.status = suspended + ensures: AuditLog.created( + event: module_suspended, + timestamp: now, + metadata: { module: module.name, reason: reason, by: admin } + ) +} + +rule ReactivateModule { + when: ReactivateModule(admin, module) + + requires: module.status = suspended + requires: module.event_types.count > 0 + + ensures: module.status = active +} + +------------------------------------------------------------ +-- Actor Declarations +------------------------------------------------------------ + +actor FrameworkRuntime { + identified_by: DomainModule where status = active +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +-- User-facing surface for module administration +surface ModuleAdministration { + facing admin: User + + exposes: + for module in DomainModules: + module.name + module.version + module.status + module.active_event_types.count + + provides: + RegisterModule(module_name, version, event_types) + for module in DomainModules where status = active: + SuspendModule(admin, module, reason) + for registration in module.active_event_types: + DeprecateEventType(module, registration.event_name) + for module in DomainModules where status = suspended: + ReactivateModule(admin, module) +} + +-- Programmatic surface: the framework-to-module integration contract +surface EventSourcingIntegration { + facing runtime: FrameworkRuntime + + context module: DomainModule where status = active + + contracts: + demands DeterministicEvaluation + fulfils EventSubmitter + fulfils StateSnapshots + + @guarantee ModuleBoundaryIsolation + -- Events and state from one module are never visible to + -- another module's evaluate function. Cross-module + -- communication happens only through side effects processed + -- by the framework. +} +``` + +**Key language features shown:** +- `contract` declarations at module level for reuse across surfaces +- Surface `contracts:` clause with `demands`/`fulfils` direction markers (`demands DeterministicEvaluation`, `fulfils EventSubmitter`) without repeating signatures or invariants +- Expression-bearing `invariant Name { expression }` on entities (`PayloadWithinLimit` on `EventSubmission`) +- Prose-only `@invariant Name` inside contracts for properties that cannot be expressed as a single boolean expression +- `@guarantee Name` at surface level, distinct from contract-scoped invariants (boundary-wide vs contract-scoped assertions) +- `@guidance` inside a contract for non-normative implementation advice +- Mixed surface: `ModuleAdministration` uses traditional `exposes`/`provides` for human actors; `EventSourcingIntegration` uses `contracts:` clause for programmatic integration +- Actor declaration for a code-level party (`FrameworkRuntime` identified by an active module) + +### When to use contracts + +Use `contract` declarations when the boundary is between code and code rather than between a user and an application. All contracts are declared at module level and referenced in surfaces via a `contracts:` clause with `demands`/`fulfils` direction markers. Common scenarios: + +- **Framework-to-plugin contracts**: the framework demands evaluation logic, fulfils lifecycle services +- **Service-to-adapter boundaries**: the service demands a storage adapter, fulfils a query interface +- **Cross-context integration**: one bounded context demands event handlers, fulfils event streams +- **SDK contracts**: the SDK demands configuration and callbacks, fulfils client operations + +Do not use contracts for user-facing surfaces. If the external party is a person interacting through a UI, use `exposes` (what they see) and `provides` (what actions they can take). Contracts describe what code must implement, not what users can do. + +### Contracts vs provides + +`provides:` lists actions that an actor can invoke, each corresponding to a rule's external stimulus trigger. `fulfils ContractName` in a `contracts:` clause declares a set of typed operations that the surface owner supplies to the counterpart as an API. The distinction: + +- `provides: SubmitEvent(module, key, name, payload)` — an action the actor triggers; a rule fires in response +- `fulfils EventSubmitter` — a typed operation set the surface makes available, defined in a `contract` declaration; the implementation is the surface owner's responsibility + +Both describe things the surface supplies, but `provides` connects to the rule system while `fulfils` references a programmatic contract with typed signatures and invariants. + +### Invariant vs guarantee + +`@guarantee` asserts a property of the surface boundary as a whole. `invariant` asserts a property scoped to the operations within a specific contract. + +Invariants come in two forms. Expression-bearing invariants carry a boolean expression that can be checked mechanically. Prose invariants describe properties that require human or LLM judgement. + +``` +-- Expression-bearing invariant on an entity +entity EventSubmission { + ... + invariant PayloadWithinLimit { length(payload) <= config.max_payload_bytes } +} + +-- Prose invariant inside a contract +contract DeterministicEvaluation { + @invariant Purity + -- evaluate must not perform I/O or access mutable state. +} + +-- Surface-level guarantee: applies across the entire boundary +@guarantee ModuleBoundaryIsolation + -- Events from one module are never visible to another module. +``` + +Use `@guarantee` for cross-cutting properties that span the whole surface. Use `invariant` for properties tied to specific operations within a contract, or for entity-level assertions. + +--- + +## Using These Patterns + +### Composition + +Patterns can be composed. For example, a complete document collaboration spec might use: + +``` +use "./rbac.allium" as rbac +use "./soft-delete.allium" as trash +use "./comments.allium" as comments +use "./notifications.allium" as notify + +entity Document { + workspace: Workspace + title: String + content: String + status: active | deleted + deleted_at: Timestamp? + deleted_by: User? + + -- From comments pattern + comments: comments/Comment with document = this + + -- From soft-delete pattern + retention_expires_at: deleted_at + trash/config.retention_period + can_restore: status = deleted and retention_expires_at > now + ... +} + +-- Document actions require RBAC checks +rule EditDocument { + when: EditDocument(user, document, content) + + let share = rbac/ResourceShare{resource: document, user: user} + + requires: share.can_edit + ... +} +``` + +### Adaptation + +Patterns are starting points. When applying: + +1. **Rename** to match your domain (User → Member, Document → Note) +2. **Adjust** timeouts and limits to your context +3. **Remove** unused states or rules +4. **Extend** with domain-specific behaviour +5. **Compose** multiple patterns for richer functionality + +### Anti-Patterns + +When using patterns, avoid: + +- **Over-engineering**: Don't include reaction system if you don't need reactions +- **Premature abstraction**: Start concrete, extract patterns when you see repetition +- **Pattern worship**: If the pattern doesn't fit, adapt it or write something custom +- **Ignoring context**: A free tier pattern that makes sense for B2C may not fit B2B diff --git a/plugins/allium/skills/allium/references/test-generation.md b/plugins/allium/skills/allium/references/test-generation.md new file mode 100644 index 0000000..ff40674 --- /dev/null +++ b/plugins/allium/skills/allium/references/test-generation.md @@ -0,0 +1,134 @@ +# Test generation + +This document describes categories of tests that a person, agent or skill should derive from an Allium specification. It is not a tool to invoke. The taxonomy maps spec constructs to test obligations; how those tests are expressed depends on the target language and test framework. + +From an Allium specification, generate: + +**Entity and value type tests** (per entity and value type): +- Verify all declared fields are present with correct types +- Verify optional fields accept null and non-null values +- Verify optional navigation (`?.`) short-circuits to null when the left side is absent +- Verify null coalescing (`??`) produces the default when the left side is null and the original value otherwise +- Verify relationships navigate to the correct related entities +- Verify join lookups (`Entity{field1, field2}`) resolve to the correct instance, or null when no match exists +- For value types, verify equality is structural (by field values, not reference) + +**Enumeration tests** (per named enum): +- Verify fields typed with the same named enum are comparable +- Verify `in` and `not in` membership tests work against set literals of enum values + +**Sum type tests** (per sum type): +- Verify each variant has its variant-specific fields accessible within a type guard +- Verify variant-specific fields are inaccessible outside type guards +- Verify all variants listed in the discriminator are handled in conditional logic +- Verify an entity cannot be multiple variants simultaneously +- Verify creation uses the variant name, not the base entity name +- Verify a `.created` trigger on the base entity fires for any variant and can be narrowed with a type guard + +**Derived value and projection tests** (per derived value or projection): +- Verify derived values compute correctly for representative entity states +- Verify projections filter correctly against their `where` predicate +- Verify projections with `-> field` mapping extract the correct field and exclude nulls +- Verify parameterised derived values return correct results for representative arguments +- Verify derived values involving `now` are volatile (re-evaluate on each read) +- Verify built-in collection operations (`.any()`, `.all()`, `.count`, set `+`/`-`, `.first`, `.last`) produce correct results +- Verify black box collection functions (free-standing calls like `filter(collection, predicate)`) are treated as opaque with implementation-defined semantics + +**Default instance tests** (per `default` declaration): +- Verify the named instance exists unconditionally +- Verify all specified field values match the declaration +- Verify cross-references between defaults resolve correctly (e.g. `inherits_from: viewer`) + +**Config tests** (per config block): +- Verify each parameter has its declared default value when not overridden +- Verify overriding a parameter replaces the default +- Verify mandatory parameters (no default) cause an error when omitted +- Verify expression-form defaults evaluate correctly, including type compatibility of arithmetic operands +- Verify qualified references to imported config resolve through the override chain +- Verify config parameters that depend on other parameters resolve in the correct order + +**Invariant tests** (per expression-bearing `invariant Name { expr }`): +- Verify the invariant holds after every state-changing rule that touches the constrained entities +- Verify the invariant holds for edge-case entity states (boundary values, empty collections) +- For entity-level invariants, verify they hold after any field mutation on the entity +- For invariants using `implies`, verify the consequent holds when the antecedent is true and that the invariant is trivially satisfied when the antecedent is false + +**Rule tests** (per rule): +- Success case: all preconditions met, verify all postconditions hold +- Failure cases: verify the rule is rejected when each `requires` clause independently fails +- Edge cases: boundary values for numeric conditions +- For conditional ensures, verify each branch fires under the correct condition and that `if` guards read resulting state, not pre-rule state +- For entity-creating ensures, verify the created entity has the specified field values +- For `let` bindings, verify the bound value is correct and available to subsequent clauses +- For `not exists` in ensures, verify the entity is removed from the system +- For bulk updates (`for` in ensures), verify the postcondition applies to every element in the collection +- For rule-level `for` iteration, verify the rule body executes for each matching element +- For chained triggers (trigger emission in ensures), verify the downstream rule fires with the correct parameters + +**State transition tests** (per entity with status enum): +- Valid transitions succeed via their rules +- Invalid transitions are rejected (no rule allows them) +- Terminal states have no outbound transitions +- For `transitions_to` triggers, verify the rule does not fire on entity creation +- For `becomes` triggers, verify the rule fires both on creation and on transition + +**State-dependent field tests** (per field with a `when` clause): +- Verify the field is present (has a meaningful value) when the entity is in a qualifying state +- Verify the field is absent (has no meaningful value) when the entity is outside the qualifying states +- When a rule transitions into the `when` set, verify it sets the field (entering obligation) +- When a rule transitions out of the `when` set, verify it clears the field (leaving obligation) +- When a rule moves within the `when` set, verify no obligation fires (field is already present) +- When two rules converge on the same qualifying state, verify both set the field +- Verify accessing a `when`-qualified field without a state guard is rejected +- For derived values computed from `when`-qualified fields, verify the inferred `when` set matches the intersection of the inputs' `when` sets + +How "present" and "absent" are tested depends on how the implementation represents the entity. When the entity is modelled as a sealed hierarchy or variant type (one class per lifecycle state), presence and absence are structural: the field exists on one variant and not another. The compiler enforces the `when` clause. When the entity is modelled as a single mutable class with nullable fields, test that the field is meaningfully populated in qualifying states and null or empty outside them. Both representations are valid for the same spec; the choice is an implementation concern. The spec-level concept is lifecycle-dependent presence; the test adapts to the representation. + +**Temporal tests** (per time-based trigger): +- Before deadline: rule does not fire, state unchanged +- At deadline: rule fires, postconditions hold +- After deadline: rule has already fired, does not re-fire +- Verify `requires` guard prevents re-firing when entity remains in the qualifying state +- Verify temporal triggers on optional fields do not fire when the field is null + +**Communication tests** (per Notification/Email/etc): +- Verify communication is triggered by the correct rule +- Verify recipient is correct +- Verify template and data are passed + +**Surface tests** (per surface): +- Exposure tests: verify each item in `exposes` is accessible to the specified party +- For `for` iteration in `exposes`, verify each element in the collection is exposed +- Provides availability: verify provided operations appear when their `when` conditions are true +- Provides unavailability: verify provided operations are hidden when `when` conditions are false, including when the corresponding rule's `requires` clauses are not met +- Actor identification: verify only entities matching the actor's `identified_by` predicate can interact +- For actors with `within`, verify interaction is scoped to the declared context (e.g. actions in one workspace do not affect another) +- Party restriction: verify the surface is not accessible to other party types +- Context scoping: verify the surface instance is absent when no entity matches the `context` predicate +- Related surface navigation: verify navigation to related surfaces resolves to the correct context entity +- Guarantee tests: verify stated `@guarantee` annotations hold across the boundary +- Timeout tests: verify the referenced temporal rule fires within the surface's context + +**Contract declaration tests** (per `contract` declaration): +- Verify the implementation satisfies each typed signature in the contract +- Verify `@invariant` annotations are honoured across the boundary +- For surfaces that `demand` a contract, verify the counterpart provides all signatures +- For surfaces that `fulfil` a contract, verify this surface supplies all signatures + +**Cross-module tests** (per `use` declaration): +- Verify qualified entity references resolve to the imported module's entities +- Verify rules that respond to external triggers (state transitions, trigger emissions) from imported modules fire correctly +- Verify external entities referenced as type placeholders accept the consuming spec's concrete type + +**Cross-rule interaction tests** (per rule with entity-creating ensures): +- Verify guards prevent duplicate entity creation when sibling rules re-trigger on the same parent +- Verify `provides` entries are unavailable when any `requires` conjunct of the corresponding rule is false + +**Scenario tests** (per specification): +- Happy path through the main rule chain (follow chained triggers from entry point to terminal state) +- Edge cases and error paths at each decision point +- When two rules could fire on the same entity, verify the resulting state is consistent regardless of order + +**Concurrency note:** The language reference does not formally define rule atomicity or evaluation order. Treat rules as atomic (completing entirely or not at all) as a reasonable default. + +**Constructs without dedicated test categories:** `given` block bindings are exercised through rule tests that reference them. `deferred` specifications are tested in their own modules. `open question` declarations are checker warnings, not test obligations. `exists` as an `if`-condition is covered by rule conditional ensures tests. Discard bindings (`_`) have no observable effect to test. diff --git a/plugins/allium/skills/distill/SKILL.md b/plugins/allium/skills/distill/SKILL.md new file mode 100644 index 0000000..5937ece --- /dev/null +++ b/plugins/allium/skills/distill/SKILL.md @@ -0,0 +1,816 @@ +--- +name: distill +description: "Extract an Allium specification from an existing codebase. Use when the user has existing code and wants to distil behaviour into a spec, reverse engineer a specification from implementation, generate a spec from code, turn implementation into a behavioural specification, or document what a codebase does in Allium terms." +--- + +# Distillation guide + +This guide covers extracting Allium specifications from existing codebases. The core challenge is the same as forward elicitation: finding the right level of abstraction. In elicitation you filter out implementation ideas as they arise. In distillation you filter out implementation details that already exist. Both require the same judgement about what matters at the domain level. + +Code tells you *how* something works. A specification captures *what* it does and *why* it matters. The skill is asking "why does the stakeholder care about this?" and "could this be different while still being the same system?" + +## Scoping the distillation effort + +Before diving into code, establish what you are trying to specify. Not every line of code deserves a place in the spec. + +### Questions to ask first + +1. **"What subset of this codebase are we specifying?"** + Mono repos often contain multiple distinct systems. You may only need a spec for one service or domain. Clarify boundaries explicitly before starting. + +2. **"Is there code we should deliberately exclude?"** + - **Legacy code**: features kept for backwards compatibility but not part of the core system + - **Incidental code**: supporting infrastructure that is not domain-level (logging, metrics, deployment) + - **Deprecated paths**: code scheduled for removal + - **Experimental features**: behind feature flags, not yet design decisions + +3. **"Who owns this spec?"** + Different teams may own different parts of a mono repo. Each team's spec should focus on their domain. + +### The "Would we rebuild this?" test + +For any code path you encounter, ask: "If we rebuilt this system from scratch, would this be in the requirements?" + +- Yes: include in spec +- No, it is legacy: exclude +- No, it is infrastructure: exclude +- No, it is a workaround: exclude (but note the underlying need it addresses) + +### Documenting scope decisions + +At the top of a distilled spec, document what is included and excluded: + +``` +-- allium: 3 +-- interview-scheduling.allium + +-- Scope: Interview scheduling flow only +-- Includes: Candidacy, Interview, InterviewSlot, Invitation, Feedback +-- Excludes: +-- - User authentication (use auth library spec) +-- - Analytics/reporting (separate spec) +-- - Legacy V1 API (deprecated, not specified) +-- - Greenhouse sync (use greenhouse library spec) +``` + +The version marker (`-- allium: N`) must be the first line of every `.allium` file. Use the current language version number. + +## Finding the right level of abstraction + +Distillation and elicitation share the same fundamental challenge: choosing what to include. The tests below work in both directions, whether you are hearing a stakeholder describe a feature or reading code that implements it. + +### The "Why" test + +For every detail in the code, ask: "Why does the stakeholder care about this?" + +| Code detail | Why? | Include? | +|-------------|------|----------| +| Invitation expires in 7 days | Affects candidate experience | Yes | +| Token is 32 bytes URL-safe | Security implementation | No | +| Sessions stored in Redis | Performance choice | No | +| Uses PostgreSQL JSONB | Database implementation | No | +| Slot status changes to 'proposed' | Affects what candidate sees | Yes | +| Email sent when invitation accepted | Communication requirement | Yes | + +If you cannot articulate why a stakeholder would care, it is probably implementation. + +### The "Could it be different?" test + +Ask: "Could this be implemented differently while still being the same system?" + +- If yes: probably implementation detail, abstract it away +- If no: probably domain-level, include it + +| Detail | Could be different? | Include? | +|--------|---------------------|----------| +| `secrets.token_urlsafe(32)` | Yes, any secure token generation | No | +| 7-day invitation expiry | No, this is the design decision | Yes | +| PostgreSQL database | Yes, any database | No | +| "Pending, Confirmed, Completed" states | No, this is the workflow | Yes | + +### The "Template vs Instance" test + +Is this a **category** of thing, or a **specific instance**? + +| Instance (often implementation) | Template (often domain-level) | +|--------------------------------|-------------------------------| +| Google OAuth | Authentication provider | +| Slack webhook | Notification channel | +| SendGrid API | Email delivery | +| `timedelta(hours=3)` | Confirmation deadline | + +Sometimes the instance IS the domain concern. See "The concrete detail problem" below. + +## The distillation mindset + +### Code is over-specified + +Every line of code makes decisions that might not matter at the domain level: + +```python +# Code tells you: +def send_invitation(candidate_id: int, slot_ids: List[int]) -> Invitation: + candidate = db.session.query(Candidate).get(candidate_id) + slots = db.session.query(InterviewSlot).filter( + InterviewSlot.id.in_(slot_ids), + InterviewSlot.status == 'confirmed' + ).all() + + invitation = Invitation( + candidate_id=candidate_id, + token=secrets.token_urlsafe(32), + expires_at=datetime.utcnow() + timedelta(days=7), + status='pending' + ) + db.session.add(invitation) + + for slot in slots: + slot.status = 'proposed' + invitation.slots.append(slot) + + db.session.commit() + + send_email( + to=candidate.email, + template='interview_invitation', + context={'invitation': invitation, 'slots': slots} + ) + + return invitation +``` + +``` +-- Specification should say: +rule SendInvitation { + when: SendInvitation(candidacy, slots) + + requires: slots.all(s => s.status = confirmed) + + ensures: + for s in slots: + s.status = proposed + ensures: Invitation.created( + candidacy: candidacy, + slots: slots, + expires_at: now + 7.days, + status: pending + ) + ensures: Email.created( + to: candidacy.candidate.email, + template: interview_invitation + ) +} +``` + +What we dropped: +- `candidate_id: int` became just `candidacy` +- `db.session.query(...)` became relationship traversal +- `secrets.token_urlsafe(32)` removed entirely (token is implementation) +- `datetime.utcnow() + timedelta(...)` became `now + 7.days` +- `db.session.add/commit` implied by `created` +- `invitation.slots.append(slot)` implied by relationship + +### Ask "Would a product owner care?" + +For every detail in the code, ask: + +| Code detail | Product owner cares? | Include? | +|-------------|---------------------|----------| +| Invitation expires in 7 days | Yes, affects candidate experience | Yes | +| Token is 32 bytes URL-safe | No, security implementation | No | +| Uses SQLAlchemy ORM | No, persistence mechanism | No | +| Email template name | Maybe, if templates are design decisions | Maybe | +| Slot status changes to 'proposed' | Yes, affects what candidate sees | Yes | +| Database transaction commits | No, implementation detail | No | + +### Distinguish means from ends + +**Means:** how the code achieves something. +**Ends:** what outcome the system needs. + +| Means (code) | Ends (spec) | +|--------------|-------------| +| `requests.post('https://slack.com/api/...')` | `Notification.created(channel: slack)` | +| `candidate.oauth_token = google.exchange(code)` | `Candidate authenticated` | +| `redis.setex(f'session:{id}', 86400, data)` | `Session.created(expires: 24.hours)` | +| `for slot in slots: slot.status = 'cancelled'` | `for s in slots: s.status = cancelled` | + +## The concrete detail problem + +The hardest judgement call: when is a concrete detail part of the domain vs just implementation? + +### Google OAuth example + +You find this code: +```python +OAUTH_PROVIDERS = { + 'google': GoogleOAuthProvider(client_id=..., client_secret=...), +} + +def authenticate(provider: str, code: str) -> User: + return OAUTH_PROVIDERS[provider].authenticate(code) +``` + +**Question:** Is "Google OAuth" domain-level or implementation? + +**It is implementation if:** +- Google is just the auth mechanism chosen +- It could be replaced with any OAuth provider +- Users do not see or care which provider +- The code is written generically (provider is a parameter) + +**It is domain-level if:** +- Users explicitly choose Google (vs Microsoft, etc.) +- "Sign in with Google" is a feature +- Google-specific scopes or permissions are used +- Multiple providers are supported as a feature + +**How to tell:** Look at the UI and user flows. If users see "Sign in with Google" as a choice, it is domain-level. If they just see "Sign in" and Google happens to be behind it, it is implementation. + +### Database choice example + +You find PostgreSQL-specific code: +```python +from sqlalchemy.dialects.postgresql import JSONB, ARRAY + +class Candidate(Base): + skills = Column(ARRAY(String)) + metadata = Column(JSONB) +``` + +**Almost always implementation.** The spec should say: +``` +entity Candidate { + skills: Set + metadata: String? -- or model specific fields +} +``` + +The specific database is rarely domain-level. Exception: if the system explicitly promises PostgreSQL compatibility or specific PostgreSQL features to users. + +### Third-party integration example + +You find Greenhouse ATS integration: +```python +class GreenhouseSync: + def import_candidate(self, greenhouse_id: str) -> Candidate: + data = self.client.get_candidate(greenhouse_id) + return Candidate( + name=data['name'], + email=data['email'], + greenhouse_id=greenhouse_id, + source='greenhouse' + ) +``` + +**Could be either:** + +**Implementation if:** +- Greenhouse is just where candidates happen to come from +- Could be swapped for Lever, Workable, etc. +- The integration is an implementation detail of "candidates are imported" + +Spec: +``` +external entity Candidate { + name: String + email: String + source: CandidateSource +} +``` + +**Product-level if:** +- "Greenhouse integration" is a selling point +- Users configure their Greenhouse connection +- Greenhouse-specific features are exposed (like syncing feedback back) + +Spec: +``` +external entity Candidate { + name: String + email: String + greenhouse_id: String? -- explicitly modeled +} + +rule SyncFromGreenhouse { + when: GreenhouseWebhookReceived(candidate_data) + ensures: Candidate.created( + ... + greenhouse_id: candidate_data.id + ) +} +``` + +### The "Multiple implementations" heuristic + +Look for variation in the codebase: + +- If there is only one OAuth provider, probably implementation +- If there are multiple OAuth providers, probably domain-level +- If there is only one notification channel, probably implementation +- If there are Slack AND email AND SMS, probably domain-level + +The presence of multiple implementations suggests the variation itself is a domain concern. + +## Distillation process + +### Step 1: Map the territory + +Before extracting any specification, understand the codebase structure: + +1. **Identify entry points.** API routes, CLI commands, message handlers, scheduled jobs. +2. **Find the domain models.** Usually in `models/`, `entities/`, `domain/`. +3. **Locate business logic.** Services, use cases, handlers. +4. **Note external integrations.** What third parties does it talk to? + +Create a rough map: +``` +Entry points: + - API: /api/candidates/*, /api/interviews/*, /api/invitations/* + - Webhooks: /webhooks/greenhouse, /webhooks/calendar + - Jobs: send_reminders, expire_invitations, sync_calendars + +Models: + - Candidate, Interview, InterviewSlot, Invitation, Feedback + +Services: + - SchedulingService, NotificationService, CalendarService + +Integrations: + - Google Calendar, Slack, Greenhouse, SendGrid +``` + +### Step 2: Extract entity states + +Look at enum fields and status columns: + +```python +class Invitation(Base): + status = Column(Enum('pending', 'accepted', 'declined', 'expired')) +``` + +Becomes: +``` +entity Invitation { + status: pending | accepted | declined | expired +} +``` + +Look for enum definitions, status or state columns, constants like `STATUS_PENDING = 'pending'`, and state machine libraries (e.g. `transitions`, `django-fsm`). + +### Step 2.5: Identify candidate processes + +After extracting entities and their states, scan for state machines that suggest end-to-end processes. Trace where each status value gets set across the codebase (where does `status = 'interviewing'` happen?). Present candidate processes to the user for validation: "I see an entity with states `applied → screening → interviewing → deciding → hired/rejected`. Is this a process the system is meant to support?" + +Also trace cross-entity data flow. If a rule on entity A requires a field from entity B, follow the chain: where does entity B's field get set, and what triggers that? Present the chain: "The hiring decision requires `background_check_status = clear`. This gets set by a webhook handler at `/api/webhooks/background-check`. Does this chain look right?" + +Generate transition graphs from the extracted rules. The graph is a derived view of the code. If it has gaps (states with no outbound transitions that aren't terminal), flag them as potential issues. + +### Step 3: Extract transitions + +Find where status changes happen: + +```python +def accept_invitation(invitation_id: int, slot_id: int): + invitation = get_invitation(invitation_id) + + if invitation.status != 'pending': + raise InvalidStateError() + if invitation.expires_at < datetime.utcnow(): + raise ExpiredError() + + slot = get_slot(slot_id) + if slot not in invitation.slots: + raise InvalidSlotError() + + invitation.status = 'accepted' + slot.status = 'booked' + + # Release other slots + for other_slot in invitation.slots: + if other_slot.id != slot_id: + other_slot.status = 'available' + + # Create the interview + interview = Interview( + candidate_id=invitation.candidate_id, + slot_id=slot_id, + status='scheduled' + ) + + notify_interviewers(interview) + send_confirmation_email(invitation.candidate, interview) +``` + +Extract: +``` +rule CandidateAcceptsInvitation { + when: CandidateAccepts(invitation, slot) + + requires: invitation.status = pending + requires: invitation.expires_at > now + requires: slot in invitation.slots + + ensures: invitation.status = accepted + ensures: slot.status = booked + ensures: + for s in invitation.slots: + if s != slot: s.status = available + ensures: Interview.created( + candidacy: invitation.candidacy, + slot: slot, + status: scheduled + ) + ensures: Notification.created(to: slot.interviewers, ...) + ensures: Email.created(to: invitation.candidate.email, ...) +} +``` + +**Key extraction patterns:** + +| Code pattern | Spec pattern | +|--------------|--------------| +| `if x.status != 'pending': raise` | `requires: x.status = pending` | +| `if x.expires_at < now: raise` | `requires: x.expires_at > now` | +| `if item not in collection: raise` | `requires: item in collection` | +| `x.status = 'accepted'` | `ensures: x.status = accepted` | +| `Model.create(...)` | `ensures: Model.created(...)` | +| `send_email(...)` | `ensures: Email.created(...)` | +| `notify(...)` | `ensures: Notification.created(...)` | + +Assertions, checks and validations found in code (e.g. `assert balance >= 0`, class-level validators) may map to expression-bearing invariants rather than rule preconditions. Consider whether they describe a system-wide property or a rule-specific guard. + +### Step 4: Find temporal triggers + +Look for scheduled jobs and time-based logic: + +```python +# In celery tasks or cron jobs +@app.task +def expire_invitations(): + expired = Invitation.query.filter( + Invitation.status == 'pending', + Invitation.expires_at < datetime.utcnow() + ).all() + + for invitation in expired: + invitation.status = 'expired' + for slot in invitation.slots: + slot.status = 'available' + notify_candidate_expired(invitation) + +@app.task +def send_reminders(): + upcoming = Interview.query.filter( + Interview.status == 'scheduled', + Interview.slot.time.between( + datetime.utcnow() + timedelta(hours=1), + datetime.utcnow() + timedelta(hours=2) + ) + ).all() + + for interview in upcoming: + send_reminder_notification(interview) +``` + +Extract: +``` +rule InvitationExpires { + when: invitation: Invitation.expires_at <= now + requires: invitation.status = pending + + ensures: invitation.status = expired + ensures: + for s in invitation.slots: + s.status = available + ensures: CandidateInformed(candidate: invitation.candidate, about: invitation_expired) +} + +rule InterviewReminder { + when: interview: Interview.slot.time - 1.hour <= now + requires: interview.status = scheduled + + ensures: Notification.created(to: interview.interviewers, template: reminder) +} +``` + +### Step 5: Identify external boundaries + +Look for third-party API calls, webhook handlers, import/export functions, and data that is read but never written (or vice versa). + +These often indicate external entities: + +```python +# Candidate data comes from Greenhouse, we don't create it +def import_from_greenhouse(webhook_data): + candidate = Candidate.query.filter_by( + greenhouse_id=webhook_data['id'] + ).first() + + if not candidate: + candidate = Candidate(greenhouse_id=webhook_data['id']) + + candidate.name = webhook_data['name'] + candidate.email = webhook_data['email'] +``` + +Suggests: +``` +external entity Candidate { + name: String + email: String +} +``` + +When repeated interface patterns appear across service boundaries (e.g. the same serialisation contract expected by multiple consumers), these suggest `contract` declarations for reuse rather than duplicated inline obligation blocks. + +### Step 5.5: Identify actors from auth patterns + +After extracting surfaces from API endpoints, identify actors by examining authentication and authorisation patterns. Different auth contexts suggest different actors: + +- API key authentication → system actor (external service) +- Role-based access (`user.role == 'admin'`) → distinct actor per role +- Scoped access (`user.org_id == resource.org_id`) → actor with `within` scoping +- Unauthenticated endpoints → public-facing actor or system webhook + +Ask the user to confirm: "This endpoint requires admin role authentication. Is 'Admin' a distinct actor, or is this the same person as the regular user with elevated permissions?" + +### Step 6: Abstract away implementation + +Now make a pass through your extracted spec and remove implementation details. + +**Before (too concrete):** +``` +entity Invitation { + candidate_id: Integer + token: String(32) + created_at: DateTime + expires_at: DateTime + status: pending | accepted | declined | expired +} +``` + +**After (domain-level):** +``` +entity Invitation { + candidacy: Candidacy + created_at: Timestamp + expires_at: Timestamp + status: pending | accepted | declined | expired + + is_expired: expires_at <= now +} +``` + +Changes: +- `candidate_id: Integer` became `candidacy: Candidacy` (relationship, not FK) +- `token: String(32)` removed (implementation) +- `DateTime` became `Timestamp` (domain type) +- Added derived `is_expired` for clarity + +Config values that derive from other config values (e.g. `extended_timeout = base_timeout * 2`) should use qualified references or expression-form defaults in the config block rather than independent literal values. + +### Step 7: Validate with stakeholders + +The extracted spec is a hypothesis. Validate it: + +1. **Show the spec to the original developers.** "Is this what the system does?" +2. **Show to stakeholders.** "Is this what the system should do?" +3. **Look for gaps.** Code often has bugs or missing features; the spec might reveal them. + +Common findings: +- "Oh, that retry logic was a hack, we should remove it" +- "Actually we wanted X but never built it" +- "These two code paths should be the same but aren't" + +Before running further checks, read [assessing specs](../allium/references/assessing-specs.md) to gauge the distilled spec's maturity. This tells you whether the spec is ready for process-level analysis or still needs structural work. + +If the Allium CLI is available, run `allium check` on the distilled spec to catch structural issues, then `allium analyse` to identify process-level gaps. Findings from `analyse` can drive validation questions: "The distilled spec has a rule that requires `background_check.status = clear` but no surface captures background check results. Is this handled by a part of the codebase we haven't looked at?" Consult [actioning findings](../allium/references/actioning-findings.md) for how to translate findings into domain questions. + +## Recognising library spec candidates + +During distillation, stay alert for code that implements generic integration patterns rather than application-specific logic. These belong in library specs. See [recognising library spec opportunities](../elicit/references/library-spec-signals.md) for the full decision framework (questions to ask, how to handle, common extractions). + +### Signals in the code + +Look for these patterns that suggest a library spec: + +**Third-party integration modules:** +```python +class StripeWebhookHandler: + def handle_invoice_paid(self, event): + ... + +class GoogleOAuthProvider: + def exchange_code(self, code): + ... +``` + +**Configuration-driven integrations:** +```python +OAUTH_CONFIG = { + 'google': {'client_id': ..., 'scopes': ...}, + 'microsoft': {'client_id': ..., 'scopes': ...}, +} +``` + +**Generic patterns with specific providers:** OAuth flows, payment processing, email delivery, calendar sync, ATS integrations, file storage. + +### Red flags: integration logic in your spec + +If you find yourself writing spec like this, stop and reconsider: + +``` +-- TOO DETAILED - this is Stripe's domain, not yours +rule ProcessStripeWebhook { + when: WebhookReceived(payload, signature) + requires: verify_stripe_signature(payload, signature) + let event = parse_stripe_event(payload) + if event.type = "invoice.paid": + ... +} +``` + +Instead: +``` +-- Application responds to payment events (integration handled elsewhere) +rule PaymentReceived { + when: stripe/InvoicePaid(invoice) + ... +} +``` + +See [patterns.md Pattern 8](../allium/references/patterns.md) for detailed examples of integrating library specs. + +## Common distillation challenges + +### Challenge: Duplicate terminology + +When you find two terms for the same concept (across specs, within a spec, or between spec and code) treat it as a blocking problem. + +``` +-- BAD: Acknowledges duplication without resolving it +-- Order vs Purchase +-- checkout.allium uses "Purchase" - these are equivalent concepts. +``` + +This is not a resolution. When different parts of a codebase are built against different specs, both terms end up in the implementation: duplicate models, redundant join tables, foreign keys pointing both ways. + +**What to do:** +- Choose one term. Cross-reference related specs before deciding. +- Update all references. Do not leave the old term in comments or "see also" notes. +- Note the rename in a changelog, not in the spec itself. + +**Warning signs in code:** +- Two models representing the same concept (`Order` and `Purchase`) +- Join tables for both (`order_items`, `purchase_items`) +- Comments like "equivalent to X" or "same as Y" + +The spec you extract must pick one term. Flag the other as technical debt to remove. + +### Challenge: Implicit state machines + +Code often has implicit states that are not modelled: + +```python +# No explicit status field, but there's a state machine hiding here +class FeedbackRequest: + interview_id = Column(Integer) + interviewer_id = Column(Integer) + requested_at = Column(DateTime) + reminded_at = Column(DateTime, nullable=True) + feedback_id = Column(Integer, nullable=True) # FK to Feedback if submitted +``` + +The implicit states are: +- `pending`: requested_at set, feedback_id null, reminded_at null +- `reminded`: reminded_at set, feedback_id null +- `submitted`: feedback_id set + +Extract to explicit: +``` +entity FeedbackRequest { + interview: Interview + interviewer: Interviewer + requested_at: Timestamp + reminded_at: Timestamp? + status: pending | reminded | submitted +} +``` + +### Challenge: Scattered logic + +The same conceptual rule might be spread across multiple places: + +```python +# In API handler +def accept_invitation(request): + if invitation.status != 'pending': + return error(400, "Already responded") + ... + +# In model +class Invitation: + def can_accept(self): + return self.expires_at > datetime.utcnow() + +# In service +def process_acceptance(invitation, slot): + if slot not in invitation.slots: + raise InvalidSlot() + ... +``` + +Consolidate into one rule: +``` +rule CandidateAccepts { + when: CandidateAccepts(invitation, slot) + + requires: invitation.status = pending + requires: invitation.expires_at > now + requires: slot in invitation.slots + ... +} +``` + +### Challenge: Dead code and historical accidents + +Codebases accumulate features that were built but never used, workarounds for bugs that are now fixed, and code paths that are never executed. + +Do not include these in the spec. If you are unsure: +1. Check if the code is actually reachable +2. Ask developers if it is intentional +3. Check git history for context + +### Challenge: Missing error handling + +Code might silently fail or have incomplete error handling: + +```python +def send_notification(user, message): + try: + slack.send(user.slack_id, message) + except SlackError: + pass # Silently ignore failures +``` + +The spec should capture the intended behaviour, not the bug: +``` +ensures: Notification.created(to: user, channel: slack) +``` + +Whether the current implementation properly handles failures is separate from what the system should do. + +### Challenge: Over-engineered abstractions + +Enterprise codebases often have abstraction layers that obscure intent: + +```java +public interface NotificationStrategy { + void notify(NotificationContext context); +} + +public class SlackNotificationStrategy implements NotificationStrategy { + @Override + public void notify(NotificationContext context) { + // Actual Slack call buried 5 levels deep + } +} +``` + +Cut through to the actual behaviour. The spec does not need strategy patterns, dependency injection or abstract factories. Just: `ensures: Notification.created(channel: slack, ...)` + +## Checklist: Have you abstracted enough? + +Before finalising a distilled spec: + +- [ ] No database column types (Integer, VARCHAR, etc.) +- [ ] No ORM or query syntax +- [ ] No HTTP status codes or API paths +- [ ] No framework-specific concepts (middleware, decorators, etc.) +- [ ] No programming language types (int, str, List, etc.) +- [ ] No variable names from the code (use domain terms) +- [ ] No infrastructure (Redis, Kafka, S3, etc.) +- [ ] Foreign keys replaced with relationships +- [ ] Tokens/secrets removed (implementation of identity) +- [ ] Timestamps use domain Duration, not timedelta/seconds + +If any remain, ask: "Would a stakeholder include this in a requirements doc?" + +## Checklist: Terminology consistency + +- [ ] Each concept has exactly one name throughout the spec +- [ ] No "also known as" or "equivalent to" comments +- [ ] Cross-referenced related specs for conflicting terms +- [ ] Duplicate models in code flagged as technical debt to remove + +## After distillation + +The extracted spec is a starting point. If distillation reveals gaps that need structured discovery (unclear requirements, complex entity relationships, unstated business rules), use the `elicit` skill to fill them. For targeted changes as requirements evolve, use the `tend` skill. For checking ongoing alignment between the spec and implementation, use the `weed` skill. + +## References + +- [Language reference](../allium/references/language-reference.md), full Allium syntax +- [Assessing specs](../allium/references/assessing-specs.md), how to assess spec maturity and choose the right level of analysis +- [Actioning findings](../allium/references/actioning-findings.md), translating checker findings into domain questions +- [Worked examples](./references/worked-examples.md), complete code-to-spec examples in Python, TypeScript and Java diff --git a/plugins/allium/skills/distill/references/worked-examples.md b/plugins/allium/skills/distill/references/worked-examples.md new file mode 100644 index 0000000..6fc46eb --- /dev/null +++ b/plugins/allium/skills/distill/references/worked-examples.md @@ -0,0 +1,999 @@ +# Worked examples: from code to spec + +These examples show real implementations in Python and TypeScript, then walk through extracting the Allium specification. + +## Example 1: Password Reset (Python/Flask) + +**The implementation:** + +```python +# models.py +from datetime import datetime, timedelta +from werkzeug.security import generate_password_hash, check_password_hash +import secrets + +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False) + password_hash = db.Column(db.String(256), nullable=False) + status = db.Column(db.String(20), default='active') + failed_attempts = db.Column(db.Integer, default=0) + locked_until = db.Column(db.DateTime, nullable=True) + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + def is_locked(self): + return (self.status == 'locked' and + self.locked_until and + self.locked_until > datetime.utcnow()) + + +class PasswordResetToken(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + token = db.Column(db.String(64), unique=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + expires_at = db.Column(db.DateTime, nullable=False) + used = db.Column(db.Boolean, default=False) + + user = db.relationship('User', backref='reset_tokens') + + @staticmethod + def generate_token(): + return secrets.token_urlsafe(32) + + def is_valid(self): + return (not self.used and + self.expires_at > datetime.utcnow()) + + +# routes.py +from flask import request, jsonify +from flask_mail import Message + +RESET_TOKEN_EXPIRY_HOURS = 1 +MAX_FAILED_ATTEMPTS = 5 +LOCKOUT_MINUTES = 15 + +@app.route('/api/auth/request-reset', methods=['POST']) +def request_password_reset(): + data = request.get_json() + email = data.get('email') + + user = User.query.filter_by(email=email).first() + if not user: + # Return success anyway to prevent email enumeration + return jsonify({'message': 'If account exists, reset email sent'}), 200 + + if user.status == 'deactivated': + return jsonify({'message': 'If account exists, reset email sent'}), 200 + + # Invalidate existing tokens + PasswordResetToken.query.filter_by( + user_id=user.id, + used=False + ).update({'used': True}) + + # Create new token + token = PasswordResetToken( + user_id=user.id, + token=PasswordResetToken.generate_token(), + expires_at=datetime.utcnow() + timedelta(hours=RESET_TOKEN_EXPIRY_HOURS) + ) + db.session.add(token) + db.session.commit() + + # Send email + reset_url = f"{app.config['FRONTEND_URL']}/reset-password?token={token.token}" + msg = Message( + 'Password Reset Request', + recipients=[user.email], + html=render_template('emails/password_reset.html', + user=user, + reset_url=reset_url) + ) + mail.send(msg) + + return jsonify({'message': 'If account exists, reset email sent'}), 200 + + +@app.route('/api/auth/reset-password', methods=['POST']) +def reset_password(): + data = request.get_json() + token_string = data.get('token') + new_password = data.get('password') + + if len(new_password) < 12: + return jsonify({'error': 'Password must be at least 12 characters'}), 400 + + token = PasswordResetToken.query.filter_by(token=token_string).first() + + if not token or not token.is_valid(): + return jsonify({'error': 'Invalid or expired token'}), 400 + + user = token.user + + # Mark token as used + token.used = True + + # Update password + user.set_password(new_password) + user.status = 'active' + user.failed_attempts = 0 + user.locked_until = None + + # Invalidate all sessions (assuming Session model exists) + Session.query.filter_by( + user_id=user.id, + status='active' + ).update({'status': 'revoked'}) + + db.session.commit() + + # Send confirmation email + msg = Message( + 'Password Changed', + recipients=[user.email], + html=render_template('emails/password_changed.html', user=user) + ) + mail.send(msg) + + return jsonify({'message': 'Password reset successful'}), 200 + + +# Scheduled job (e.g., celery task) +@celery.task +def cleanup_expired_tokens(): + """Run hourly to mark expired tokens""" + PasswordResetToken.query.filter( + PasswordResetToken.used == False, + PasswordResetToken.expires_at < datetime.utcnow() + ).update({'used': True}) + db.session.commit() +``` + +**Extraction process:** + +1. **Identify entities from models:** + - `User` - has email, password_hash, status, failed_login_attempts, locked_until + - `PasswordResetToken` - has user, token, created_at, expires_at, used + +2. **Identify states from status fields and booleans:** + - User status: `active | locked | deactivated` (found in code) + - Token: `used` boolean, convert to status: `pending | used | expired` + +3. **Identify triggers from routes/handlers:** + - `request_password_reset` - external trigger + - `reset_password` - external trigger + - `cleanup_expired_tokens` - temporal trigger + +4. **Extract preconditions from validation:** + - `if not user` becomes `requires: exists user` + - `len(new_password) < 12` becomes `requires: length(password) >= 12` + - `token.is_valid()` becomes `requires: token.is_valid` + +5. **Extract postconditions from mutations:** + - `token.used = True` becomes `ensures: token.status = used` + - `user.set_password(...)` becomes `ensures: user.password_hash = hash(password)` + - `mail.send(msg)` becomes `ensures: Email.created(...)` + +6. **Strip implementation details:** + - Remove: `secrets.token_urlsafe(32)`, `generate_password_hash`, `db.session` + - Remove: HTTP status codes, JSON responses + - Remove: `render_template`, URL construction + - Keep: durations (1 hour, 12 characters) + +**Extracted Allium spec:** + +``` +-- password-reset.allium + +config { + reset_token_expiry: Duration = 1.hour + min_password_length: Integer = 12 +} + +entity User { + email: String + password_hash: String + status: active | locked | deactivated + failed_login_attempts: Integer + locked_until: Timestamp? + + reset_tokens: PasswordResetToken with user = this + sessions: Session with user = this + + active_sessions: sessions where status = active + pending_reset_tokens: reset_tokens where status = pending +} + +entity PasswordResetToken { + user: User + created_at: Timestamp + expires_at: Timestamp + status: pending | used | expired + + is_valid: status = pending and expires_at > now +} + +rule RequestPasswordReset { + when: UserRequestsPasswordReset(email) + + let user = User{email} + + requires: exists user + requires: user.status in {active, locked} + + ensures: + for t in user.pending_reset_tokens: + t.status = expired + ensures: + let token = PasswordResetToken.created( + user: user, + created_at: now, + expires_at: now + config.reset_token_expiry, + status: pending + ) + Email.created( + to: user.email, + template: password_reset, + data: { token: token } + ) +} + +rule CompletePasswordReset { + when: UserResetsPassword(token, new_password) + + requires: token.is_valid + requires: length(new_password) >= config.min_password_length + + let user = token.user + + ensures: token.status = used + ensures: user.password_hash = hash(new_password) + ensures: user.status = active + ensures: user.failed_login_attempts = 0 + ensures: user.locked_until = null + ensures: + for s in user.active_sessions: + s.status = revoked + ensures: Email.created(to: user.email, template: password_changed) +} + +rule ResetTokenExpires { + when: token: PasswordResetToken.expires_at <= now + requires: token.status = pending + ensures: token.status = expired +} +``` + +**What we removed:** +- Database details (SQLAlchemy, column types, foreign keys) +- HTTP layer (routes, JSON, status codes) +- Security implementation (token generation algorithm, password hashing) +- Email enumeration protection (design decision, could add back if desired) +- Template rendering details + +--- + +## Example 2: Usage Limits (TypeScript/Node) + +**The implementation:** + +```typescript +// models/plan.ts +export interface Plan { + id: string; + name: string; + maxProjects: number; // -1 for unlimited + maxStorageMB: number; // -1 for unlimited + maxTeamMembers: number; + monthlyPriceUsd: number; + features: string[]; +} + +export const PLANS: Record = { + free: { + id: 'free', + name: 'Free', + maxProjects: 3, + maxStorageMB: 100, + maxTeamMembers: 1, + monthlyPriceUsd: 0, + features: ['basic_editor'], + }, + pro: { + id: 'pro', + name: 'Pro', + maxProjects: 50, + maxStorageMB: 10000, + maxTeamMembers: 10, + monthlyPriceUsd: 15, + features: ['basic_editor', 'advanced_editor', 'api_access'], + }, + enterprise: { + id: 'enterprise', + name: 'Enterprise', + maxProjects: -1, + maxStorageMB: -1, + maxTeamMembers: -1, + monthlyPriceUsd: 99, + features: ['basic_editor', 'advanced_editor', 'api_access', 'sso', 'audit_log'], + }, +}; + +// models/workspace.ts +export interface Workspace { + id: string; + name: string; + ownerId: string; + planId: string; + createdAt: Date; +} + +// services/usage.service.ts +import { prisma } from '../db'; +import { PLANS } from '../models/plan'; + +export class UsageService { + async getWorkspaceUsage(workspaceId: string) { + const [projectCount, storageBytes, memberCount] = await Promise.all([ + prisma.project.count({ where: { workspaceId, deletedAt: null } }), + prisma.file.aggregate({ + where: { project: { workspaceId } }, + _sum: { sizeBytes: true }, + }), + prisma.workspaceMember.count({ where: { workspaceId } }), + ]); + + return { + projects: projectCount, + storageMB: Math.ceil((storageBytes._sum.sizeBytes || 0) / 1024 / 1024), + members: memberCount, + }; + } + + async canCreateProject(workspaceId: string): Promise { + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + }); + if (!workspace) return false; + + const plan = PLANS[workspace.planId]; + if (plan.maxProjects === -1) return true; + + const usage = await this.getWorkspaceUsage(workspaceId); + return usage.projects < plan.maxProjects; + } + + async canAddMember(workspaceId: string): Promise { + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + }); + if (!workspace) return false; + + const plan = PLANS[workspace.planId]; + if (plan.maxTeamMembers === -1) return true; + + const usage = await this.getWorkspaceUsage(workspaceId); + return usage.members < plan.maxTeamMembers; + } + + async canUploadFile(workspaceId: string, fileSizeBytes: number): Promise { + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + }); + if (!workspace) return false; + + const plan = PLANS[workspace.planId]; + if (plan.maxStorageMB === -1) return true; + + const usage = await this.getWorkspaceUsage(workspaceId); + const newStorageMB = usage.storageMB + Math.ceil(fileSizeBytes / 1024 / 1024); + return newStorageMB <= plan.maxStorageMB; + } + + hasFeature(planId: string, feature: string): boolean { + const plan = PLANS[planId]; + return plan?.features.includes(feature) ?? false; + } +} + +// controllers/project.controller.ts +import { UsageService } from '../services/usage.service'; + +const usageService = new UsageService(); + +export async function createProject(req: Request, res: Response) { + const { workspaceId, name } = req.body; + const userId = req.user.id; + + // Check membership + const membership = await prisma.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId, userId } }, + }); + + if (!membership) { + return res.status(403).json({ error: 'Not a member of this workspace' }); + } + + // Check limits + const canCreate = await usageService.canCreateProject(workspaceId); + if (!canCreate) { + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + include: { plan: true }, + }); + + return res.status(403).json({ + error: 'Project limit reached', + code: 'LIMIT_REACHED', + limit: PLANS[workspace!.planId].maxProjects, + upgradeUrl: '/settings/billing', + }); + } + + const project = await prisma.project.create({ + data: { + workspaceId, + name, + createdById: userId, + }, + }); + + // Track usage event + await prisma.usageEvent.create({ + data: { + workspaceId, + type: 'PROJECT_CREATED', + metadata: { projectId: project.id }, + }, + }); + + return res.status(201).json(project); +} + +// controllers/billing.controller.ts +export async function changePlan(req: Request, res: Response) { + const { workspaceId, newPlanId } = req.body; + const userId = req.user.id; + + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + }); + + if (!workspace || workspace.ownerId !== userId) { + return res.status(403).json({ error: 'Only owner can change plan' }); + } + + const currentPlan = PLANS[workspace.planId]; + const newPlan = PLANS[newPlanId]; + + if (!newPlan) { + return res.status(400).json({ error: 'Invalid plan' }); + } + + // Check if downgrading + const isDowngrade = newPlan.monthlyPriceUsd < currentPlan.monthlyPriceUsd; + + if (isDowngrade) { + const usage = await usageService.getWorkspaceUsage(workspaceId); + + // Validate limits + if (newPlan.maxProjects !== -1 && usage.projects > newPlan.maxProjects) { + return res.status(400).json({ + error: 'Cannot downgrade: too many projects', + code: 'DOWNGRADE_BLOCKED', + current: usage.projects, + limit: newPlan.maxProjects, + mustDelete: usage.projects - newPlan.maxProjects, + }); + } + + if (newPlan.maxStorageMB !== -1 && usage.storageMB > newPlan.maxStorageMB) { + return res.status(400).json({ + error: 'Cannot downgrade: storage exceeds limit', + code: 'DOWNGRADE_BLOCKED', + currentMB: usage.storageMB, + limitMB: newPlan.maxStorageMB, + }); + } + + if (newPlan.maxTeamMembers !== -1 && usage.members > newPlan.maxTeamMembers) { + return res.status(400).json({ + error: 'Cannot downgrade: too many team members', + code: 'DOWNGRADE_BLOCKED', + current: usage.members, + limit: newPlan.maxTeamMembers, + }); + } + } + + await prisma.workspace.update({ + where: { id: workspaceId }, + data: { planId: newPlanId }, + }); + + // Send email notification + const owner = await prisma.user.findUnique({ where: { id: workspace.ownerId } }); + await sendEmail({ + to: owner!.email, + template: isDowngrade ? 'plan_downgraded' : 'plan_upgraded', + data: { oldPlan: currentPlan.name, newPlan: newPlan.name }, + }); + + return res.json({ success: true, plan: newPlan }); +} +``` + +**Extraction process:** + +1. **Identify entities from types/models:** + - `Plan` - configuration entity with limits + - `Workspace` - has owner, plan + - `WorkspaceMembership` - join entity (user + workspace) + - `Project`, `File` - resources that count against limits + - `UsageEvent` - audit/tracking + +2. **Identify derived values from service methods:** + - `canCreateProject()` becomes a derived boolean on Workspace + - `canAddMember()` becomes a derived boolean + - `hasFeature()` becomes a derived function + +3. **Recognize the "unlimited" pattern:** + - `-1` means unlimited, convert to explicit handling + +4. **Identify triggers from controllers:** + - `createProject` - external trigger with limit check + - `changePlan` - external trigger with downgrade validation + +5. **Extract the permission/limit pattern:** + - Check membership becomes `requires: exists membership` + - Check limit becomes `requires: workspace.can_add_project` + - Return error with upgrade path becomes a separate rule for limit reached + +**Extracted Allium spec:** + +``` +-- usage-limits.allium + +entity Plan { + name: String + max_projects: Integer -- -1 = unlimited + max_storage_mb: Integer + max_team_members: Integer + monthly_price: Decimal + features: Set -- domain type; define in your spec + + has_unlimited_projects: max_projects = -1 + has_unlimited_storage: max_storage_mb = -1 + has_unlimited_members: max_team_members = -1 +} + +entity Workspace { + name: String + owner: User + plan: Plan + + members: WorkspaceMembership with workspace = this + all_projects: Project with workspace = this + + -- Projections + projects: all_projects where deleted_at = null + + -- Usage calculations + project_count: projects.count + storage_mb: calculate_storage(this) -- black box + member_count: members.count + + -- Limit checks + can_add_project: + plan.has_unlimited_projects + or project_count < plan.max_projects + + can_add_member: + plan.has_unlimited_members + or member_count < plan.max_team_members + + can_add_storage(size_mb): + plan.has_unlimited_storage + or storage_mb + size_mb <= plan.max_storage_mb + + can_use_feature(f): f in plan.features +} + +entity WorkspaceMembership { + workspace: Workspace + user: User +} + +rule CreateProject { + when: CreateProject(user, workspace, name) + + let membership = WorkspaceMembership{workspace, user} + + requires: exists membership + requires: workspace.can_add_project + + ensures: Project.created( + workspace: workspace, + name: name, + created_by: user + ) + ensures: UsageEvent.created( + workspace: workspace, + type: project_created + ) +} + +rule CreateProjectLimitReached { + when: CreateProject(user, workspace, name) + + let membership = WorkspaceMembership{workspace, user} + + requires: exists membership + requires: not workspace.can_add_project + + ensures: UserInformed( + user: user, + about: limit_reached, + data: { + limit_type: projects, + current: workspace.project_count, + max: workspace.plan.max_projects + } + ) +} + +rule ChangePlan { + when: ChangePlan(user, workspace, new_plan) + + requires: user = workspace.owner + + let is_downgrade = new_plan.monthly_price < workspace.plan.monthly_price + let old_plan = workspace.plan + + requires: not is_downgrade + or (workspace.project_count <= new_plan.max_projects + or new_plan.has_unlimited_projects) + requires: not is_downgrade + or (workspace.storage_mb <= new_plan.max_storage_mb + or new_plan.has_unlimited_storage) + requires: not is_downgrade + or (workspace.member_count <= new_plan.max_team_members + or new_plan.has_unlimited_members) + + ensures: workspace.plan = new_plan + ensures: Email.created( + to: workspace.owner.email, + template: if is_downgrade: plan_downgraded else: plan_upgraded, + data: { old_plan: old_plan, new_plan: new_plan } + ) +} + +rule DowngradeBlocked { + when: ChangePlan(user, workspace, new_plan) + + requires: user = workspace.owner + requires: new_plan.monthly_price < workspace.plan.monthly_price + requires: workspace.project_count > new_plan.max_projects + and not new_plan.has_unlimited_projects + + ensures: UserInformed( + user: user, + about: downgrade_blocked, + data: { + reason: projects, + current: workspace.project_count, + limit: new_plan.max_projects + } + ) +} +``` + +**What we removed:** +- Prisma queries and database access patterns +- HTTP layer (Express req/res, status codes) +- Promise.all parallelisation +- Math.ceil for storage calculation +- JSON error response structure +- Compound unique key syntax + +**What we kept:** +- The -1 unlimited convention (could also use explicit `unlimited` type) +- Plan structure with features +- The paired success/failure rule pattern +- Usage event tracking + +--- + +## Example 3: Soft Delete (Java/Spring) + +**The implementation:** + +```java +// entities/Document.java +@Entity +@Table(name = "documents") +@Where(clause = "deleted_at IS NULL") // Default filter +public class Document { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String id; + + @Column(nullable = false) + private String title; + + @Column(columnDefinition = "TEXT") + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "workspace_id", nullable = false) + private Workspace workspace; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by_id", nullable = false) + private User createdBy; + + @Column(nullable = false) + private Instant createdAt; + + @Column + private Instant deletedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "deleted_by_id") + private User deletedBy; + + public boolean isDeleted() { + return deletedAt != null; + } + + public boolean canRestore() { + if (deletedAt == null) return false; + Instant retentionDeadline = deletedAt.plus(Duration.ofDays(30)); + return Instant.now().isBefore(retentionDeadline); + } +} + +// repositories/DocumentRepository.java +public interface DocumentRepository extends JpaRepository { + + // This ignores the @Where clause to include deleted documents + @Query("SELECT d FROM Document d WHERE d.workspace.id = :workspaceId") + List findAllIncludingDeleted(@Param("workspaceId") String workspaceId); + + @Query("SELECT d FROM Document d WHERE d.workspace.id = :workspaceId AND d.deletedAt IS NOT NULL") + List findDeleted(@Param("workspaceId") String workspaceId); + + @Query("SELECT d FROM Document d WHERE d.workspace.id = :workspaceId AND d.deletedAt IS NOT NULL AND d.deletedAt > :cutoff") + List findRestorable(@Param("workspaceId") String workspaceId, @Param("cutoff") Instant cutoff); + + @Modifying + @Query("DELETE FROM Document d WHERE d.deletedAt IS NOT NULL AND d.deletedAt < :cutoff") + int permanentlyDeleteExpired(@Param("cutoff") Instant cutoff); +} + +// services/DocumentService.java +@Service +@Transactional +public class DocumentService { + + private static final Duration RETENTION_PERIOD = Duration.ofDays(30); + + @Autowired + private DocumentRepository documentRepository; + + @Autowired + private WorkspaceMemberRepository memberRepository; + + public void softDelete(String documentId, String userId) { + Document document = documentRepository.findById(documentId) + .orElseThrow(() -> new NotFoundException("Document not found")); + + if (document.isDeleted()) { + throw new IllegalStateException("Document already deleted"); + } + + // Check permission: creator or admin + boolean isCreator = document.getCreatedBy().getId().equals(userId); + boolean isAdmin = memberRepository.isAdmin(document.getWorkspace().getId(), userId); + + if (!isCreator && !isAdmin) { + throw new ForbiddenException("Not authorized to delete this document"); + } + + document.setDeletedAt(Instant.now()); + document.setDeletedBy(userRepository.findById(userId).orElseThrow()); + + documentRepository.save(document); + } + + public void restore(String documentId, String userId) { + // Bypass @Where to find deleted document + Document document = documentRepository.findAllIncludingDeleted(documentId) + .stream() + .filter(d -> d.getId().equals(documentId)) + .findFirst() + .orElseThrow(() -> new NotFoundException("Document not found")); + + if (!document.canRestore()) { + throw new IllegalStateException("Document cannot be restored"); + } + + // Check permission: original deleter or admin + boolean isDeleter = document.getDeletedBy().getId().equals(userId); + boolean isAdmin = memberRepository.isAdmin(document.getWorkspace().getId(), userId); + + if (!isDeleter && !isAdmin) { + throw new ForbiddenException("Not authorized to restore this document"); + } + + document.setDeletedAt(null); + document.setDeletedBy(null); + + documentRepository.save(document); + } + + public void permanentlyDelete(String documentId, String userId) { + Document document = documentRepository.findAllIncludingDeleted(documentId) + .stream() + .filter(d -> d.getId().equals(documentId)) + .findFirst() + .orElseThrow(() -> new NotFoundException("Document not found")); + + if (!document.isDeleted()) { + throw new IllegalStateException("Document must be soft-deleted first"); + } + + boolean isAdmin = memberRepository.isAdmin(document.getWorkspace().getId(), userId); + if (!isAdmin) { + throw new ForbiddenException("Only admins can permanently delete"); + } + + documentRepository.delete(document); + } + + public void emptyTrash(String workspaceId, String userId) { + boolean isAdmin = memberRepository.isAdmin(workspaceId, userId); + if (!isAdmin) { + throw new ForbiddenException("Only admins can empty trash"); + } + + List deleted = documentRepository.findDeleted(workspaceId); + documentRepository.deleteAll(deleted); + } +} + +// scheduled/RetentionCleanupJob.java +@Component +public class RetentionCleanupJob { + + @Autowired + private DocumentRepository documentRepository; + + @Scheduled(cron = "0 0 2 * * *") // Run at 2 AM daily + @Transactional + public void cleanupExpiredDocuments() { + Instant cutoff = Instant.now().minus(Duration.ofDays(30)); + int deleted = documentRepository.permanentlyDeleteExpired(cutoff); + log.info("Permanently deleted {} expired documents", deleted); + } +} +``` + +**Extraction process:** + +1. **Spot the soft delete pattern:** + - `deletedAt` timestamp (nullable) instead of status enum + - `@Where` clause for default filtering + - Separate queries to include/exclude deleted + +2. **Extract the implicit state machine:** + - `deletedAt = null` means active + - `deletedAt != null` means deleted + - `deleted` removes from database, meaning permanently deleted + +3. **Identify the retention policy:** + - `Duration.ofDays(30)` is a config value + - `canRestore()` method is a derived value + +4. **Extract permission rules:** + - Delete: creator OR admin + - Restore: original deleter OR admin + - Permanent delete: admin only + +**Extracted Allium spec:** + +``` +-- soft-delete.allium + +config { + retention_period: Duration = 30.days +} + +entity Document { + workspace: Workspace + title: String + content: String + created_by: User + created_at: Timestamp + status: active | deleted + deleted_at: Timestamp? + deleted_by: User? + + retention_expires_at: deleted_at + config.retention_period + can_restore: status = deleted and retention_expires_at > now +} + +entity Workspace { + all_documents: Document with workspace = this + + documents: all_documents where status = active + deleted_documents: all_documents where status = deleted + restorable_documents: all_documents where can_restore = true +} + +rule DeleteDocument { + when: DeleteDocument(actor, document) + + let membership = WorkspaceMembership{workspace: document.workspace, user: actor} + + requires: document.status = active + requires: actor = document.created_by or membership.can_admin + + ensures: document.status = deleted + ensures: document.deleted_at = now + ensures: document.deleted_by = actor +} + +rule RestoreDocument { + when: RestoreDocument(actor, document) + + let membership = WorkspaceMembership{workspace: document.workspace, user: actor} + + requires: document.can_restore + requires: actor = document.deleted_by or membership.can_admin + + ensures: document.status = active + ensures: document.deleted_at = null + ensures: document.deleted_by = null +} + +rule PermanentlyDelete { + when: PermanentlyDelete(actor, document) + + let membership = WorkspaceMembership{workspace: document.workspace, user: actor} + + requires: document.status = deleted + requires: membership.can_admin + + ensures: not exists document +} + +rule EmptyTrash { + when: EmptyTrash(actor, workspace) + + let membership = WorkspaceMembership{workspace: workspace, user: actor} + + requires: membership.can_admin + + ensures: + for d in workspace.deleted_documents: + not exists d +} + +rule RetentionExpires { + when: document: Document.retention_expires_at <= now + requires: document.status = deleted + ensures: not exists document +} +``` + +**Key observations:** + +The Java code uses `deletedAt != null` as the delete indicator, but the spec uses an explicit `status` field. Both are valid approaches. The spec is more explicit about state, while the code uses a convention. The spec captures the *meaning* (document is either active or deleted) without prescribing the implementation (status enum vs nullable timestamp). diff --git a/plugins/allium/skills/elicit/SKILL.md b/plugins/allium/skills/elicit/SKILL.md new file mode 100644 index 0000000..daff3e6 --- /dev/null +++ b/plugins/allium/skills/elicit/SKILL.md @@ -0,0 +1,368 @@ +--- +name: elicit +description: "Run a structured discovery session to build an Allium specification through conversation. Use when the user wants to create a new spec from scratch, elicit or gather requirements, capture domain behaviour, specify a feature or system, define what a system should do, or is describing functionality and needs help shaping it into a specification." +--- + +# Elicitation + +This skill guides you through building Allium specifications by conversation. The goal is to surface ambiguities and produce a specification that captures what the software does without prescribing implementation. + +## Scoping the specification + +Before diving into details, establish what you are specifying. Not everything needs to be in one spec. + +### Questions to ask first + +**"What's the boundary of this specification?"** A complete system? A single feature area? One service in a larger system? Be explicit about what is in and out of scope. + +**"Are there areas we should deliberately exclude?"** Third-party integrations might be library specs. Legacy features might not be worth specifying. Some features might belong in separate specs. + +**"Is this a new system or does code already exist?"** If code exists, you are doing distillation with elicitation. Existing code constrains what is realistic to specify. + +### Documenting scope decisions + +Capture scope at the start of every spec: + +``` +-- allium: 3 +-- interview-scheduling.allium + +-- Scope: Interview scheduling for the hiring pipeline +-- Includes: Candidacy, Interview, Slot management, Invitations, Feedback +-- Excludes: +-- - Authentication (use oauth library spec) +-- - Payments (not applicable) +-- - Reporting dashboards (separate spec) +-- Dependencies: User entity defined in core.allium +``` + +The version marker (`-- allium: N`) must be the first line of every `.allium` file. Use the current language version number. + +## Finding the right level of abstraction + +Too concrete and you are specifying implementation. Too abstract and you are not saying anything useful. + +### The "Why" test + +For every detail, ask: "Why does the stakeholder care about this?" + +| Detail | Why? | Include? | +|--------|------|----------| +| "Users log in with Google OAuth" | They need to authenticate | Maybe not, "Users authenticate" might be sufficient | +| "We support Google and Microsoft OAuth" | Users choose their provider | Yes, the choice is domain-level | +| "Sessions expire after 24 hours" | Security/UX decision | Yes, affects user experience | +| "Sessions are stored in Redis" | Performance | No, implementation detail | +| "Passwords must be 12+ characters" | Security policy | Yes, affects users | +| "Passwords are hashed with bcrypt" | Security implementation | No, how not what | + +### The "Could it be different?" test + +Ask: "Could this be implemented differently while still being the same system?" + +- If yes, it is probably an implementation detail. Abstract it away. +- If no, it is probably domain-level. Include it. + +Examples: + +- "Notifications sent via Slack". Could be email, SMS, etc. Abstract to `Notification.created(channel: ...)`. +- "Interviewers must confirm within 3 hours". This specific deadline matters at the domain level. Include the duration. +- "We use PostgreSQL". Could be any database. Do not include. +- "Data is retained for 7 years for compliance". Regulatory requirement. Include. + +### The "Template vs Instance" test + +Is this a category of thing, or a specific instance? + +| Instance (implementation) | Template (domain-level) | +|---------------------------|-------------------------| +| Google OAuth | Authentication provider | +| Slack | Notification channel | +| 15 minutes | Link expiry duration (configurable) | +| Greenhouse ATS | External candidate source | + +Sometimes the instance IS the domain concern. "We specifically integrate with Salesforce" might be a competitive feature. "We support exactly these three OAuth providers" might be design scope. + +When in doubt, ask the stakeholder: "If we changed this, would it be a different system or just a different implementation?" + +### Levels of abstraction + +``` +Too abstract: "Users can do things" + | +Product level: "Candidates can accept or decline interview invitations" + | +Too concrete: "Candidates click a button that POST to /api/invitations/:id/accept" +``` + +**Signs you are too abstract.** The spec could describe almost any system. No testable assertions. Product owner says "but that doesn't capture..." + +**Signs you are too concrete.** You are mentioning technologies, frameworks or APIs. You are describing UI elements (buttons, pages, forms). The implementation team says "why are you dictating how we build this?" + +### Configuration vs hardcoding + +When you encounter a specific value (3 hours, 7 days, etc.), ask: + +1. **Is this value a design decision?** Include it. +2. **Might it vary per deployment or customer?** Make it configurable. +3. **Is it arbitrary?** Consider whether to include it at all. + +``` +-- Hardcoded design decision +rule InvitationExpires { + when: invitation: Invitation.created_at + 7.days <= now + ... +} + +-- Configurable +config { + invitation_expiry: Duration = 7.days +} + +rule InvitationExpires { + when: invitation: Invitation.created_at + config.invitation_expiry <= now + ... +} +``` + +### Black boxes + +Some logic is important but belongs at a different level: + +``` +-- Black box: we know it exists and what it considers, but not how +ensures: Suggestion.created( + interviewers: InterviewerMatching.suggest( + considering: { + role.required_skills, + Interviewer.skills, + Interviewer.availability, + Interviewer.recent_load + } + ) +) +``` + +The spec says there is a matching algorithm, that it considers these inputs and that it produces interviewer suggestions. The spec does not say how matching works, what weights are used or the specific algorithm. + +This is the right level when the algorithm is complex and evolving, when product owners care about inputs and outputs rather than internals, and when a separate detailed spec could cover it if needed. + +## Reading the initial prompt + +Before choosing an approach, assess what the user is bringing. The initial prompt tells you where to start. + +**The user describes a process.** "We have a hiring pipeline where candidates apply, get screened, interview, then we decide." They're thinking at the process level. Start with process discovery — let them describe the flow, then help organise it into spec constructs. Consult [process discovery](./references/process-discovery.md). + +**The user names entities.** "I need to spec an Order entity with states and transitions." They're already thinking at the construct level. Skip process discovery and move to scope definition, then fill in detail. Consult [detail elicitation](./references/detail-elicitation.md) when working through rules and surfaces. + +**The user has a vague idea.** "We need to build something for managing customer support." They need help shaping the idea before specifying it. Start with process discovery using open questions: "Tell me about what happens when a customer reaches out for help." Consult [process discovery](./references/process-discovery.md). + +**The user has existing code.** "We have a payments service and I want to capture what it does." This is distillation with elicitation. Point them to the `distill` skill, or combine both: distill the structure from code, elicit the intent from the stakeholder. + +**The user has an existing spec.** Read the spec first. Use [assessing specs](../allium/references/assessing-specs.md) to determine what level of development each entity is at. Skip phases the spec has already covered — don't re-ask scope questions for a spec that already has scope comments, or re-discover processes for a spec that already has transition graphs. Start at the level each entity needs: detail elicitation for entities with lifecycles but no rules, obstacle elicitation for entities with rules but no failure paths. + +## Elicitation methodology + +### Phase 0: Process discovery + +**Goal:** Understand the processes the system supports before identifying constructs. + +Not every session needs this phase. If the user arrives with entities and lifecycles already in mind, skip to Phase 1. If they arrive with a process description or a vague idea, start here. + +Let the user describe the system in their own words before imposing Allium structure. Capture the process, the actors, the outcomes, then organise into constructs. See [process discovery](./references/process-discovery.md) for specific techniques. + +**Outputs:** Process names and outcomes. Rough sequence of steps. Actors identified. Enough to write a coarse spec (entities with transition graphs and open questions). + +**Watch for:** The urge to jump to entity definitions too early. Stay at the process level until the flow is clear. + +### Phase 1: Scope definition + +**Goal:** Understand what we are specifying and where the boundaries are. + +Questions to ask: + +1. "What is this system fundamentally about? In one sentence?" +2. "Where does this system start and end? What's in scope vs out?" +3. "Who are the users? Are there different roles?" +4. "Are there existing systems this integrates with? What do they handle?" + +If Phase 0 was skipped, also ask: "What are the key processes this system supports? What does success look like for each?" This anchors entity identification to processes rather than enumerating nouns in isolation. The techniques in [process discovery](./references/process-discovery.md) apply here too — use past tense recall and outcome-first questioning if the user struggles to articulate the process. + +**Outputs:** List of actors and roles. List of core entities (derived from the process if Phase 0 ran). Boundary decisions (what is external). One-sentence description. + +**Watch for:** Scope creep ("and it also does X, Y, Z", gently refocus). Assumed knowledge ("obviously it handles auth", make explicit). Descriptions that suggest a [library spec](./references/library-spec-signals.md) rather than application-specific logic (e.g. OAuth, payment processing, email delivery). + +### Phase 2: Happy path flow + +**Goal:** Trace the main journey from start to finish. + +If Phase 0 produced a walking skeleton (see [process discovery](./references/process-discovery.md)), use it as the starting point. Otherwise, ask: "If we could only build one path through this process, what would it be?" Write the skeleton as a coarse spec and describe it back to the user in domain terms (see [assessing specs](../allium/references/assessing-specs.md#communicating-with-stakeholders)). + +Then flesh out: "What triggers each step? Who's involved? What changes?" Follow one entity through its lifecycle, capturing state transitions, actors and triggers. + +``` +Candidacy: + applied -> screening -> interviewing -> deciding -> hired | rejected +``` + +**Outputs:** Transition graphs for key entities. Main triggers and their outcomes. Actor assignments at each step. + +**Watch for:** Jumping to edge cases too early ("but what if...", note it and stay on happy path). Implementation details creeping in ("the API endpoint...", redirect to outcomes). + +After writing spec constructs, run `allium check` if the CLI is available. Fix structural issues before continuing — don't wait until Phase 4 to validate. + +After establishing the skeleton, consult [detail elicitation](./references/detail-elicitation.md) for techniques on filling in rules, surfaces, fields and data dependencies. + +### Phase 3: Edge cases and failure paths + +**Goal:** Discover what can go wrong and how the system handles it. + +Consult [obstacle elicitation](./references/obstacle-elicitation.md) for techniques. The key approaches: + +- Use the pre-mortem: "Imagine this system has been built and it's failing. What went wrong?" +- At each step: "What if nobody does anything here? After a day? A week?" +- At each handoff: "Who takes over? How do they know it's their turn? What do they need to see?" +- At each transition: "What if the preconditions aren't met? Can this be reversed?" +- For external dependencies: "How does this information enter the system? What if the external service is unavailable?" + +**Outputs:** Exception transitions. Temporal triggers with `requires` guards. Escalation paths. Terminal error states. Invariants. + +**Watch for:** Infinite loops ("then it retries, then retries again...", need terminal states). Missing escalation, because eventually a human needs to know. + +When stakeholders state system-wide properties ("balance never goes negative", "no two interviews overlap for the same candidate"), these are candidates for top-level invariants. Capture them as `invariant Name { expression }` declarations. + +After writing rules and exception transitions, run `allium check` if the CLI is available. Fix issues before moving to refinement. + +### Phase 4: Refinement + +**Goal:** Verify and complete the specification. + +Consult [assumption checking](./references/assumption-checking.md) for techniques. Describe what the spec says in domain terms and test it against the user's mental model. Trace concrete scenarios through the spec. Test ordering assumptions. Verify actor assignments. + +If the Allium CLI is available, run `allium check` and use diagnostics to identify structural gaps. If `allium analyse` is available and the spec has rules and surfaces, run it and use findings to surface process-level gaps. Consult [actioning findings](../allium/references/actioning-findings.md) for how to translate findings into domain questions. + +Questions to ask: + +1. "Looking at [entity], are these states complete? Can it be in any other state?" +2. "Is there anything we haven't covered?" +3. "This rule references [X], do we need to define that, or is it external?" +4. "Is this detail essential here, or should it live in a detailed spec?" + +**Technique:** Take a concrete scenario and trace it through the spec. "Let's say Alice applies for the Senior Engineer role. Walk me through what happens to her candidacy." + +**Outputs:** Complete entity definitions. Open questions documented. Deferred specifications identified. External boundaries confirmed. + +When the same obligation pattern (e.g. a serialisation contract, a deterministic evaluation requirement) appears across multiple surfaces, suggest extracting it as a `contract` declaration for reuse. + +## Elicitation principles + +### Ask one question at a time + +Bad: "What entities do you have, and what states can they be in, and who can modify them?" + +Good: "What are the main things this system manages?" +Then: "Let's take [Candidacy]. What states can it be in?" +Then: "Who can change a candidacy's state?" + +### Work through implications + +When a choice arises, do not just accept the first answer. Explore consequences. + +"You said invitations expire after 48 hours. What happens then?" +"And if the candidate still hasn't responded after we retry?" +"What if they never respond, is this candidacy stuck forever?" + +This surfaces decisions they have not made yet. + +### Distinguish product from implementation + +When you hear implementation language, redirect: + +| They say | You redirect | +|----------|-------------| +| "The API returns a 404" | "So the user is informed it's not found?" | +| "We store it in Postgres" | "What information is captured?" | +| "The frontend shows a modal" | "The user is prompted to confirm?" | +| "We use a cron job" | "This happens on a schedule, how often?" | + +### Surface ambiguity explicitly + +Better to record an open question than assume. + +"I'm not sure whether declining should return the candidate to the pool or remove them entirely. Let me note that as an open question." + +``` +open question "When candidate declines, do they return to pool or exit?" +``` + +### Iterate willingly + +It is normal to revise earlier decisions. + +"Earlier we said all admins see all notifications. But now you're describing role-specific dashboards. Should we revisit that?" + +### Prioritise depth over breadth + +Fully develop the most important entity first. Leave others coarse with open questions. The user can return to flesh them out in a later session. Trying to develop every entity to the same level in one conversation risks context exhaustion without completing anything. + +### Know when to stop + +Not everything needs to be specified now. + +"This is getting into how the matching algorithm works. Should we defer that to a detailed spec?" + +"We've covered the main flow. The reporting dashboard sounds like a separate specification." + +## Common elicitation traps + +### The "Obviously" trap + +When someone says "obviously" or "of course", probe. "You said obviously the admin approves. Is there ever a case where they don't need to? Could this be automated later?" + +### The "Edge Case Spiral" trap + +Some people want to cover every edge case immediately. "Let's capture that as an open question and stay on the main flow for now. We'll come back to edge cases." + +### The "Vague Agreement" trap + +Do not accept "yes" without specifics. "You said yes, candidates can reschedule. How many times? Is there a limit? What happens after that?" + +### The "Missing Actor" trap + +Watch for actions without clear actors. "You said 'the slots are released'. Who or what releases them? Is it automatic, or does someone trigger it?" + +### The "Equivalent Terms" trap + +When you hear two terms for the same concept, from different stakeholders, existing code or related specs, stop and resolve it before continuing. + +"You said 'Purchase' but earlier we called this an 'Order'. Which term should we use?" + +A comment noting that two terms are equivalent is not a resolution. It guarantees both will appear in the implementation. Pick one term, cross-reference related specs and update all references. Do not leave the old term anywhere, not even in "see also" notes. + +## Elicitation session structure + +These timings apply to human-facilitated sessions. In an LLM conversation, use the phase outputs to decide when to advance rather than watching the clock. + +**Opening.** Explain Allium briefly: "We're capturing what the software does, not how it's built." Agree on scope for this session. + +**Scope definition.** Identify actors, entities, boundaries. Get the one-sentence description. + +**Happy path.** Trace main flow start to finish. Capture states, triggers, outcomes. + +**Edge cases.** Timeouts and deadlines. Failure modes. Escalation paths. + +**Wrap-up.** Read back key decisions. List open questions. Name which entities are still coarse and what they need next. Identify next session scope if needed. + +## After elicitation + +For targeted changes where you already know what you want, use the `tend` skill. For substantial additions that need structured discovery (new feature areas, complex entity relationships, unclear requirements), elicit is still the right tool even if a spec already exists. Checking alignment between specs and implementation belongs to the `weed` skill. + +## References + +- [Language reference](../allium/references/language-reference.md), full Allium syntax +- [Assessing specs](../allium/references/assessing-specs.md), how to assess spec maturity and choose the right level of analysis +- [Actioning findings](../allium/references/actioning-findings.md), translating checker findings into domain questions +- [Process discovery](./references/process-discovery.md), techniques for when the user hasn't articulated the process yet +- [Detail elicitation](./references/detail-elicitation.md), techniques for filling in rules, surfaces and data dependencies +- [Obstacle elicitation](./references/obstacle-elicitation.md), techniques for exploring failure paths, timeouts and handoffs +- [Assumption checking](./references/assumption-checking.md), techniques for verifying the spec matches the user's mental model +- [Recognising library spec opportunities](./references/library-spec-signals.md), signals, questions and decision framework for identifying library specs during elicitation diff --git a/plugins/allium/skills/elicit/references/assumption-checking.md b/plugins/allium/skills/elicit/references/assumption-checking.md new file mode 100644 index 0000000..f7b029f --- /dev/null +++ b/plugins/allium/skills/elicit/references/assumption-checking.md @@ -0,0 +1,53 @@ +# Assumption checking + +Use these techniques when you have a coarse or complete spec and need to verify it matches the user's mental model. Show-back and ordering checks work on coarse specs (transition graphs without rules). Scenario traces require rules and surfaces to be defined. Actor verification works at any stage. + +## Show back what you've heard + +After capturing a process or a set of rules, write the spec, then describe what it says in domain language. Don't present raw Allium syntax — translate constructs into a narrative the stakeholder can validate. + +"Based on what you've described, here's the lifecycle for Candidacy. Applied, then screening, then interviewing, then deciding, and from there either hired or rejected. Screening can also lead directly to rejection. Is this right?" + +Let the user correct, refine and extend. Common responses: +- "Yes, but you're missing X" → add the missing transition or entity +- "Not quite — Y happens before Z" → reorder the transitions +- "What about W?" → the user remembered something they hadn't mentioned + +## Test ordering assumptions + +When the transition graph is taking shape, test whether the declared ordering is correct. + +"Could these steps happen in a different order? What if the background check completed before screening was finished — would that change anything?" + +This surfaces: +- **False ordering constraints** — steps the user assumed were sequential but could be parallel +- **Missing concurrency** — two things that can happen simultaneously but the graph forces them into sequence +- **Hidden dependencies** — steps that truly must follow a specific order, revealing data dependencies + +If the user says "those could happen in either order", the transition graph may need restructuring. If they say "no, X absolutely must happen before Y", ask why — the answer is usually a data dependency that should be a `requires` clause. + +## Verify actor assignments + +After identifying actors and their surfaces, check the assignments. + +"I have the recruiter screening candidates and the hiring manager making the final decision. Is it always the hiring manager? Could a recruiter make the decision for junior roles?" + +Actor boundaries are often assumed rather than decided. Testing them reveals: +- **Role overlap** — two actors who can do the same thing, needing explicit modelling +- **Delegation** — one actor acting on behalf of another +- **Conditional assignment** — different actors for different entity states or types + +## Check completeness at transition points + +When moving from one entity to the next, or from happy path to edge cases, pause and check. + +"Before we move on to interviews — looking at the screening flow, is there anything we haven't covered? Any situation that could come up that we haven't accounted for?" + + +## Verify against real scenarios + +Take a concrete scenario and trace it through the spec. + +"Let's say Alice applies for the Senior Engineer role on Monday. Walk me through what happens to her candidacy using the spec we've written. Does each step match what you'd expect?" + +If the spec produces a different outcome than the user expects, you've found a gap. The gap might be a missing rule, a wrong guard, or an unstated assumption. diff --git a/plugins/allium/skills/elicit/references/detail-elicitation.md b/plugins/allium/skills/elicit/references/detail-elicitation.md new file mode 100644 index 0000000..4f39c1b --- /dev/null +++ b/plugins/allium/skills/elicit/references/detail-elicitation.md @@ -0,0 +1,64 @@ +# Detail elicitation + +Use these techniques when an entity has a lifecycle (transition graph) but needs rules, surfaces, fields and data dependencies filled in. The shape is known; the detail isn't. + +## Start from examples, not abstractions + +Before writing rules, collect concrete scenarios. Ask for at least two specific cases. + +"Give me a case where someone was hired. Now give me one where they were rejected at screening. What was different?" + +The differences between the cases reveal the `requires` guards. The commonalities reveal the `ensures` outcomes. Rules emerge from comparing scenarios rather than being defined in the abstract. + +When a rule is ambiguous or the user can't articulate the conditions, ask for more examples. "Can you give me a case where this went a different way?" Each new example narrows the rule. + +## Actor walkthrough + +Pick a specific human actor and walk through their perspective in first person. For system actors (external APIs, background services), use third person instead: "The payment gateway receives a charge request. What does it need? What does it return?" + +"You're the recruiter. You open the system on Monday morning. What's in front of you?" The answer is surface `exposes` — the data the actor sees. + +"What can you do from here?" The answer is surface `provides` — the actions available. + +"When would this action not be available?" The answer is the `when` guard on the provides clause. + +"After you've done that, what happens next? Who takes over?" The answer reveals the handoff to the next actor and the next surface. + +## Trace data flow backward + +When you encounter a decision point or a rule with preconditions, work backward from the requirement. + +"The hiring manager needs to see interview feedback before deciding. Where does that feedback come from? Who provides it? At what point in the process?" + +Each "where does this come from?" reveals a data dependency. Follow the chain until you reach a surface where an actor enters the data or an external system provides it. If the chain ends without a source, you've found a gap — a `missing_producer` in checker terms. + +## Ground abstract descriptions + +When a user describes something abstractly ("the system shows relevant information"), ground it with a concrete question. + +"If you were looking at the screen right now, what would you see? What specific information?" This surfaces the exact fields that need to be in `exposes`. + +"Can you sketch what that screen looks like, in words? What's at the top? What's the main content?" + +## Prompt for external system boundaries + +When a step depends on data from outside the system, ask how it enters. + +"You mentioned the background check results come back. How does that happen? Does someone enter them manually, or does an external service send them automatically?" + +The answer determines whether you need a surface facing a human actor or a contract integration point facing a system. Many process gaps involve external systems (payment processors, identity verification, notification services) where the spec needs an entry point but the user assumes the data just appears. + +## What to produce + +If an entity's transition graph has grown beyond eight or so states, consider whether the lifecycle should be split. A booking entity that spans request, rental, inspection and deposit settlement might be clearer as separate entities linked by relationships. Ask the user: "This entity is covering a lot of ground. Would it be clearer to separate the [X] phase from the [Y] phase into its own entity?" + +At the end of detail elicitation for an entity, you should have: + +- **Fields** with types, including state-dependent fields (`when` clauses) +- **Rules** witnessing every transition, with `requires` and `ensures` +- **Surfaces** for each actor that interacts with this entity, with `exposes` and `provides` +- **Relationships** connecting this entity to related entities +- **Config** for any variable values (durations, thresholds, limits) +- **Open questions** for anything unresolved + +Write the spec, then describe what it says in domain language and verify it with the user before moving on (see [assumption checking](assumption-checking.md)). diff --git a/plugins/allium/skills/elicit/references/library-spec-signals.md b/plugins/allium/skills/elicit/references/library-spec-signals.md new file mode 100644 index 0000000..16e032a --- /dev/null +++ b/plugins/allium/skills/elicit/references/library-spec-signals.md @@ -0,0 +1,108 @@ +# Recognising library spec opportunities + +During elicitation, stay alert for descriptions that suggest a library spec rather than application-specific logic. Library specs are standalone specifications for generic integrations that could be reused across projects. + +This applies equally to distillation. When examining existing code and finding OAuth flows or payment processing, the same questions apply. + +## Signals that something might be a library spec + +**External system integration:** + +- "We use Google/Microsoft/GitHub for login" +- "Payments go through Stripe/PayPal" +- "We send emails via SendGrid/Postmark" +- "Calendar invites sync with Google Calendar" +- "We store files in S3/GCS" + +**Generic patterns being described:** + +- OAuth flows, session management, token refresh +- Payment processing, subscriptions, invoicing +- Email delivery, bounce handling, unsubscribes +- File upload, virus scanning, thumbnail generation +- Webhook receipt, retry logic, signature verification + +**Implementation-agnostic descriptions:** + +- "Users log in with their work account" (could be any SSO provider) +- "We charge them monthly" (could be any payment processor) +- "They get notified" (could be any notification infrastructure) + +## Questions to ask + +When you detect a potential library spec, pause and explore: + +1. **"Is this specific to your system, or is it a standard integration?"** If standard, it is likely a library spec candidate. + +2. **"Would another system integrating with [X] work the same way?"** If yes, it is definitely a library spec candidate. + +3. **"Do you have specific customisations to how [X] works, or is it standard?"** Standard behaviour points to a library spec. Heavy customisation might still be a library spec with configuration. + +4. **"Should we look for an existing library spec for [X], or do you need something custom?"** This encourages reuse and saves effort. + +## How to handle the decision + +**Option 1: Use an existing library spec** + +"It sounds like you're describing a standard OAuth flow. There's likely an existing library spec for this. Shall we reference that rather than specifying the OAuth details here? Your application spec would just respond to authentication events." + +**Option 2: Create a new library spec** + +"The way you're describing this Greenhouse ATS integration sounds generic enough that it could be its own library spec. Other hiring applications might integrate with Greenhouse the same way. Should we create a separate greenhouse-ats.allium spec that this application references?" + +**Option 3: Keep it inline (rare)** + +"This integration is so specific to your system that it probably doesn't make sense as a standalone spec. Let's include it directly." + +## Common library spec candidates + +| Domain | Likely library specs | +|--------|---------------------| +| Authentication | OAuth providers (Google, Microsoft, GitHub), SAML, magic links | +| Payments | Stripe, PayPal, subscription billing, usage-based billing | +| Communications | Email delivery, SMS, push notifications, Slack/Teams | +| Storage | S3-compatible storage, file scanning, image processing | +| Calendar | Google Calendar, Outlook, iCal feeds | +| CRM/ATS | Salesforce, HubSpot, Greenhouse, Lever | +| Analytics | Segment, Mixpanel, event tracking | +| Infrastructure | Webhook handling, rate limiting, audit logging | + +## The boundary question + +When you identify a library spec candidate, the key question is: "Where does the library spec end and the application spec begin?" + +The library spec handles: + +- The mechanics of the integration (OAuth flow, payment processing) +- Events that any consumer would care about (login succeeded, payment failed) +- Configuration that varies between deployments + +The application spec handles: + +- What happens in your system when those events occur +- Application-specific entities (your User, your Subscription) +- Business rules unique to your domain + +Example boundary: + +``` +-- Library spec (oauth.allium) handles: +-- - Provider configuration +-- - Token exchange +-- - Session lifecycle +-- - Emits: AuthenticationSucceeded, SessionExpired, etc. + +-- Application spec handles: +-- - Creating your User entity on first login +-- - What roles/permissions new users get +-- - Blocking suspended users from logging in +-- - Audit logging specific to your compliance needs +``` + +## Red flags you missed a library spec + +During review, watch for: + +- **Detailed protocol descriptions.** "First we redirect to Google, then they redirect back with a code, then we exchange it for a token..." This is OAuth. Use a library spec. +- **Vendor-specific details.** "Stripe sends a webhook with event type `invoice.paid`..." This is Stripe integration. Use a library spec. +- **Repeated patterns.** If you are specifying similar retry/timeout/error handling for multiple integrations, extract a common pattern. diff --git a/plugins/allium/skills/elicit/references/obstacle-elicitation.md b/plugins/allium/skills/elicit/references/obstacle-elicitation.md new file mode 100644 index 0000000..68f0b4a --- /dev/null +++ b/plugins/allium/skills/elicit/references/obstacle-elicitation.md @@ -0,0 +1,70 @@ +# Obstacle elicitation + +Use these techniques when exploring failure paths, timeouts, exception transitions and actor handoffs. + +## Use the pre-mortem + +Instead of the abstract "what can go wrong?", use a concrete framing. + +"Imagine it's six months from now. This system has been built and deployed. Something has gone wrong and people are frustrated. What happened?" + +People are better at imagining concrete failure than listing abstract risks. The pre-mortem produces vivid, specific failure modes rather than generic edge cases. Each failure mode maps to an exception transition, a timeout rule, or an invariant. + +Follow up each failure with: "How should the system have prevented that? Or handled it?" The answer is the rule or guard that's missing from the spec. + +## Ask what happens when nothing happens + +At every step where a human actor needs to act, ask: "What if nobody does anything? After a day? After a week?" + +The answer is one of: +- "Nothing, it just waits." This is a design decision worth making explicit. Document it as the intended behaviour, possibly with an open question about whether it's acceptable. +- "After X time, Y happens." This is a temporal trigger: `when: entity.timestamp_field + config.duration <= now` with `requires: entity.status = expected_state` to prevent re-firing. Do not use `becomes` for time-delayed behaviour — `becomes` fires immediately when an entity enters a state, not after a delay. +- "Someone should be notified." This surfaces a notification or escalation path. + +Most specs underspecify inaction. The happy path assumes everyone acts promptly. Real systems have stale candidacies, expired invitations and abandoned carts. These need rules. + +## Explore handoffs between actors + +At every state transition, ask: "Who takes over at this point? How do they know it's their turn? What do they need to see?" + +The answers reveal: +- **Actor transitions** — which actor is responsible for the next step +- **Notification needs** — how the next actor learns they need to act +- **Information requirements** — what the next actor's surface must expose +- **Related surface links** — how surfaces connect to each other + +Handoffs are where processes break in practice. The outgoing actor assumes the incoming actor knows what happened. The incoming actor assumes they'll be told. The spec needs to make the handoff explicit: what triggers the notification, what information it carries, and what the next actor sees when they arrive. + +## Enumerate alternatives at each step + +For each step in the happy path, systematically ask: "What else could happen here?" Keep asking until the user can't think of anything more. This is the discipline that prevents gaps: stories and informal descriptions only capture the paths someone happens to think of. Enumeration forces completeness. + +Work through the happy path step by step: +1. State the step: "At this point, the recruiter reviews the application." +2. Ask: "What's the main thing that happens?" (The happy path outcome — already captured.) +3. Ask: "What else could happen?" (First alternative — maybe rejection.) +4. Ask: "Anything else?" (Second alternative — maybe deferral, or requesting more information.) +5. Keep asking until exhausted. +6. For each alternative: "What happens next if this path is taken?" (Follow the alternative to its terminal state.) + +Each alternative becomes either an exception transition in the graph, an additional rule, or an open question if the user isn't sure. If an alternative branches into its own multi-step flow, capture it as an open question and return to it in a later pass rather than following every branch immediately. + +## Systematically test each transition + +After enumeration, test each transition for robustness: +- "What if the preconditions aren't met? What should happen?" +- "Can this transition be reversed? Can someone undo it?" +- "Is there a time limit on being in this state?" +- "Can this transition happen more than once?" + +For critical entities, test every transition. For less critical entities, focus on the transitions most likely to fail or stall. Critical entities are those central to the system's value proposition, those that handle money or compliance-sensitive data, or those the user mentioned during the pre-mortem. Transitions most likely to stall are those that depend on external actors or systems, those with temporal dependencies, and those where a human must act. + +## What to capture + +Obstacle elicitation produces: +- **Exception transitions** (screening → rejected, interview → cancelled) +- **Temporal triggers** with `requires` guards (invitation expires after 48 hours) +- **Escalation paths** (stuck candidacy → notify recruiter after 5 days) +- **Terminal error states** (background check flagged → candidacy terminated) +- **Invariants** (system-wide properties that must hold: "no candidate can have two active candidacies for the same role") +- **Open questions** for unresolved failure scenarios diff --git a/plugins/allium/skills/elicit/references/process-discovery.md b/plugins/allium/skills/elicit/references/process-discovery.md new file mode 100644 index 0000000..58c2c0f --- /dev/null +++ b/plugins/allium/skills/elicit/references/process-discovery.md @@ -0,0 +1,68 @@ +# Process discovery + +Use these techniques when the user hasn't articulated the process yet, when they're starting from scratch, or when you need to understand the shape of a system before getting into construct-level detail. + +## Let the user talk first + +Before imposing any Allium structure, let the user describe the process in their own words. Don't interrupt for entity types, field names or state transitions. Capture the raw description, then organise it into constructs afterward. If the description becomes unclear or contradictory, ask a brief clarifying question, but don't redirect into Allium constructs yet. + +Prompt with: "Tell me about this system. What does it do?" or "Walk me through the main thing that happens, start to finish." + +## Use past tense + +When the user struggles to articulate a process in the abstract, switch to past tense. Recalling what happened is easier than prescribing what should happen. + +"Tell me about the last time someone was hired at your company" produces richer material than "describe the hiring process." Follow up with "and then what happened?" to walk the timeline. The events become rule triggers, the actors become actors, the decisions become guards. + +## Start from outcomes + +Most people can name what they're trying to achieve before they can describe how they get there. Ask about the destination before asking about the route. + +"What does success look like for this process?" or "When this process finishes well, what's the result?" The answer gives you the terminal states. Then work backward: "What has to happen before that? And before that?" + +If there are multiple outcomes (hired vs rejected, fulfilled vs refunded), capture them all. They define the shape of the transition graph. + +## Find the walking skeleton + +Once you have a rough sense of the process, ask: "If we could only build one path through this, what would it be? The simplest journey from start to finish." + +The answer is the happy path — the coarse spec. Entities with transition graphs showing the main flow. Everything else (exception paths, alternative flows, edge cases) is added incrementally. + +Once you have the skeleton, write it as a coarse Allium spec (entities with transition graphs, actors, open questions) and describe it back to the user in domain language for validation — don't present raw syntax. The skeleton is the transition from free-form discovery to formalisation. + +## Identify actors early + +Ask "who's involved?" early in the conversation. For each actor: "What do they need to do their job?" and "What do they need to see?" + +Each actor's perspective is a partial view of the process. The full process emerges from composing these views. If two actors describe the same step differently, you've found either an ambiguity or a handoff that needs clarifying. + +## Layered decomposition + +For complex processes, work through layers in order. Each layer surfaces a different kind of Allium construct. Ask about each layer before moving to the next. + +1. **Events.** "What are the things that happen in this process?" Capture in past tense ("candidate applied", "background check completed", "offer accepted"). These become entity state transitions and rule triggers. +2. **Commands.** "For each event, what triggered it? A person doing something, or the system reacting?" Commands from people become surface `provides` actions. System reactions become rules with `becomes` or `transitions_to` triggers. +3. **Actors.** "Who issued each command? Which role or system?" Each distinct role or system becomes an actor declaration. +4. **Entities.** "Which thing in the system changed when this event happened?" Group events by the entity they affect. Each group becomes an entity with a lifecycle. +5. **Policies.** "Are there any automatic reactions — whenever X happens, Y should follow?" These become rules with chained triggers or `becomes` triggers. +6. **Information needs.** "At each decision point, what did the actor need to see to make the decision?" These become surface `exposes` and reveal data dependencies between entities. +7. **Unknowns.** "Is there anything here you're not sure about, or where different people would give different answers?" These become `open_questions`. + +This layered approach produces a richer set of constructs than open-ended conversation. Use it when the process involves multiple actors, crosses entity boundaries, or when the user gives detailed but unstructured descriptions that need organising. For processes with a single actor and a straightforward lifecycle, the techniques above (outcomes-first, walking skeleton) are sufficient. + +## What to capture + +Whether using layered decomposition or open-ended discovery, note: + +- **Events** (things that happen) → entity state transitions, rule triggers +- **Actors** (people or systems involved) → actor declarations +- **Decisions** (choices someone makes) → rule guards, alternative transitions +- **Information needs** ("they need to see X to decide") → surface exposes, data dependencies +- **Outcomes** (what success and failure look like) → terminal states +- **Unknowns** ("I'm not sure how that works") → open questions + +Before finding the walking skeleton, capture as prose notes or simple bullet lists ("Candidate applied → recruiter screened → interviews happened → decision made"). Don't use Allium syntax yet. After the skeleton is clear, organise into Allium constructs and describe the result back to the user in domain terms for correction. + +## When to stop + +Process discovery is complete when you can write the walking skeleton: you know the main entities, their lifecycle states, the actors involved and the terminal outcomes. You don't need every detail — that's what later phases provide. If you have enough to write a coarse spec with transition graphs and open questions, move on. diff --git a/plugins/allium/skills/propagate/SKILL.md b/plugins/allium/skills/propagate/SKILL.md new file mode 100644 index 0000000..44ca8ee --- /dev/null +++ b/plugins/allium/skills/propagate/SKILL.md @@ -0,0 +1,216 @@ +--- +name: propagate +description: "Generate tests from Allium specifications. Use when the user wants to propagate tests, generate test files from a spec, write tests for a specification, create property-based tests, produce state machine tests, check test coverage against spec obligations, or understand what tests a specification requires." +--- + +# Propagation + +This skill generates tests from Allium specifications. Propagation is how plants reproduce from cuttings of the parent: the spec is the parent, the tests are the offspring. + +Deterministic tools guarantee completeness (every spec construct maps to a test obligation). You handle the implementation bridge: correlating spec constructs with code, generating tests in the project's conventions. + +## Prerequisites + +Before propagating tests, you need: + +1. **An Allium spec** — the `.allium` file describing the system's behaviour +2. **A target codebase** — the implementation to test +3. **Test obligations** — from `allium plan ` (JSON listing every required test) +4. **Domain model** — from `allium model ` (JSON describing entity shapes, constraints, state machines) + +If the CLI tools are not available, derive test obligations manually from the spec using the test-generation taxonomy in [`references/test-generation.md`](../allium/references/test-generation.md). + +## Modes + +### Surface mode + +Generates boundary tests from surface declarations. Use when the user wants to test an API, UI contract or integration boundary. + +For each surface in the spec: + +1. **Exposure tests** — verify each item in `exposes` is accessible to the specified actor, including `for` iteration over collections +2. **Provides tests** — verify operations appear when their `when` conditions are true and are hidden otherwise, including when the corresponding rule's `requires` clauses are not met +3. **Actor restriction tests** — verify the surface is not accessible to other actor types +4. **Actor identification tests** — verify only entities matching the actor's `identified_by` predicate can interact; for actors with `within`, verify interaction is scoped to the declared context +5. **Context scoping tests** — verify the surface instance is absent when no entity matches the `context` predicate +6. **Contract obligation tests** — verify `demands` are satisfied by the counterpart, `fulfils` are supplied by this surface, including all typed signatures +7. **Guarantee tests** — verify `@guarantee` annotations hold across the boundary +8. **Timeout tests** — verify referenced temporal rules fire within the surface's context +9. **Related navigation tests** — verify navigation to related surfaces resolves to the correct context entity + +### Spec mode + +Walks the full test obligations document. Use when the user wants comprehensive test coverage for the entire specification. + +Categories from the test-generation taxonomy: + +- **Entity and value type tests** — fields, types, optional (`?`) null handling, `when`-clause state-dependent presence, relationships, join lookups, equality +- **Enum tests** — comparability across named enums, membership tests, inline enum isolation +- **Sum type tests** — variant fields, type guards, exhaustiveness, creation via variant name, base `.created` trigger narrowing +- **Derived value and projection tests** — computation, filtering, `-> field` extraction, parameterised derived values, `now` volatility, collection operations +- **Default instance tests** — unconditional existence, field values, cross-references between defaults +- **Config tests** — defaults, overrides, mandatory parameters, expression-form defaults, qualified references, config chains +- **Invariant tests** — post-rule verification, edge cases, implication logic, entity-level invariants +- **Rule tests** — success/failure/edge cases, conditionals (ensuring `if` guards read resulting state), entity creation, removal, bulk updates, rule-level `for` iteration, `let` bindings, chained triggers +- **State transition tests** — valid/invalid transitions, terminal states, `transitions_to` vs `becomes` semantics +- **Temporal tests** — deadline boundaries, re-firing prevention, optional field null behaviour +- **Surface tests** — exposure, availability, actor identification with `within` scoping, context scoping, related navigation +- **Contract tests** — signature satisfaction, `@invariant` honouring, `demands`/`fulfils` direction +- **Cross-module tests** — qualified entity references, external trigger responses, type placeholder substitution +- **Cross-rule interaction tests** — duplicate creation guards, provides availability +- **Transition graph tests** — every declared edge is reachable via its witnessing rule, undeclared transitions are rejected, terminal states have no outbound rules, non-terminal states have at least one exit, exact correspondence between enum values and graph edges +- **State-dependent field tests** — presence when in qualifying state, absence when outside, presence obligations on entering the `when` set, absence obligations on leaving, no obligation when moving within or outside, convergent transitions all set the field, guard required to access `when`-qualified fields, derived value `when` inference via input intersection +- **Scenario tests** — happy path, edge cases, order independence +- **Data flow chain tests** — exercise full chains from surface capture through rules to downstream rule preconditions. For each chain (surface provides trigger → rule ensures field → downstream rule requires field), generate an integration test that submits data through the surface and verifies it reaches the downstream precondition. +- **Reachability tests** — walk from each initial state (via `.created()`) to each terminal state, following a valid path through the transition graph. Each test exercises a complete lifecycle. +- **Deadlock scenario tests** — for states where `allium analyse` identifies potential deadlocks, generate tests that put the entity in the stuck state and verify whether it can progress. +- **Cross-entity process tests** — for processes spanning multiple entities, generate integration tests that exercise the full process from start to terminal state across all participating entities. + +If `allium analyse` is available, use its findings to prioritise test generation. A `missing_producer` or `dead_transition` finding indicates a gap worth exercising with a test. A `deadlock` finding should generate a test documenting that the entity cannot escape the stuck state. Consult [actioning findings](../allium/references/actioning-findings.md) for the finding type taxonomy. + +## Test output kinds + +### 1. Assertion-based tests + +For deterministic obligations: field presence, enum membership, transition validity, surface exposure, state-dependent field presence and absence. These are standard unit/integration tests. + +### 2. Property-based tests + +For invariants and rule properties. Each expression-bearing invariant becomes a PBT property: +- Generate a valid entity state using the generator spec +- Apply a sequence of rules (following the transition graph when declared, or deriving valid sequences from rules alone) +- Check the invariant holds at every step + +Use the project's PBT framework: + +| Language | Framework | Discovery | +|----------|-----------|-----------| +| TypeScript | fast-check | `package.json` | +| Python | Hypothesis | `pyproject.toml` | +| Rust | proptest | `Cargo.toml` | +| Go | rapid | `go.mod` | +| Elixir | StreamData | `mix.exs` | + +Fall back to assertion-based tests if no PBT framework is present. + +### 3. State machine tests + +For entities with status enums. When a transition graph is declared, walk every path through the graph. When no graph is declared, derive valid transitions from rules. +- Verify transitions succeed via witnessing rules +- Verify rejected transitions fail +- Verify state-dependent fields are present or absent at each state per their `when` clauses +- Verify invariants hold at each state + +State machine tests require an **action map**: a function per transition edge that takes the entity in the source state and produces it in the target state by calling the actual implementation code. Without this map, the test framework can describe valid paths through the graph but cannot execute them. + +To build the action map: +1. For each edge in the transition graph, find the witnessing rule in the spec +2. Find the code implementing that rule (the implementation bridge) +3. Write a test action that sets up the preconditions (`requires` clauses), invokes the code, and returns the entity in the target state +4. Register the action under the `(from_state, to_state)` key + +Once the map is built, the PBT framework can walk random valid paths: start at any non-terminal state, pick a random outbound edge, apply its action, check all entity-level invariants, repeat. The path length and starting state are generated randomly. This is the fullest expression of the spec's transition graph as a test. + +## The implementation bridge + +You correlate spec constructs with implementation code, the same way the weed skill correlates for divergence checking. + +### For surface tests + +Map surfaces to their implementation: +- API surfaces map to endpoints (REST routes, GraphQL resolvers, gRPC services) +- UI surfaces map to components or pages +- Integration surfaces map to message handlers or SDK methods + +Discover the mapping by reading the codebase. Look for naming patterns, route definitions and handler registrations. + +### For internal tests + +For each rule in the spec: +1. Find the code implementing the rule (service method, event handler, state machine transition) +2. Determine how to instantiate the entities involved (factories, builders, fixtures) +3. Determine how to invoke the rule (API call, method call, event dispatch) +4. Determine how to assert postconditions (database queries, return values, event assertions) + +### For temporal tests + +Temporal triggers (deadline-based rules) need a controllable time source in the test. If the implementation uses wall-clock time (`Instant.now()`, `System.currentTimeMillis()`), the test cannot reliably position itself before, at or after a deadline. + +Before attempting temporal tests, check whether the component accepts an injected clock or time parameter. Common patterns: a `Clock` parameter on the constructor, an epoch-millisecond argument on the method, a `TimeProvider` interface. If the seam exists, inject a controllable time source. If it does not, flag this as a test infrastructure gap: the temporal tests cannot be generated until the component supports time injection. Do not attempt to test temporal behaviour by sleeping or racing against wall-clock time. + +### For cross-module trigger chains + +When a rule emits a trigger that another spec's rule receives (e.g. the Arbiter emits `ClerkReceivesEvent`, the Clerk handles it), testing the chain requires multiple components wired together. + +Before generating cross-module tests: +1. Trace the trigger emission graph from the plan output: which rules emit triggers, and which rules in other specs receive them +2. Check whether the codebase has an existing integration test fixture that wires the participating components (a pipeline test, an end-to-end test helper, a test harness class) +3. If a fixture exists, reuse it. Cross-module tests should compose existing wiring, not rebuild it +4. If no fixture exists but the codebase structure is clear enough to understand the wiring (service constructors, dependency injection, event bus configuration), generate the fixture and the test +5. If the wiring is too complex or opaque to generate confidently, generate a test skeleton with TODOs marking where component wiring is needed + +Cross-module tests are integration tests by nature. They verify that the spec's trigger chains are faithfully implemented across component boundaries. Prioritise them after single-component tests are passing. + +### Reusing existing tests + +When exploring the codebase, note which spec obligations are already covered by existing tests. An existing integration test that exercises the happy path from event submission through to acknowledged output already covers multiple `rule_success` obligations and the end-to-end scenario. + +When an existing test covers a spec obligation, reference it rather than generating a duplicate. The propagate skill's value at the integration level is verifying that coverage is complete against the spec's obligation list, identifying gaps, and generating tests to fill them. Replacing working hand-written tests with generated equivalents adds no value. + +### For deferred specs + +Deferred specifications are fully specified in separate files. When the target codebase doesn't include the deferred spec's module, generate a test stub with a placeholder: + +```typescript +// TODO: deferred spec — InterviewerMatching.suggest +// This behaviour is specified as deferred. Provide a mock or skip. +``` + +## Process + +1. **Read the spec** — understand entities, rules, surfaces, invariants, transition graphs, state-dependent fields, contracts, config, defaults. Read [assessing specs](../allium/references/assessing-specs.md) to gauge the spec's maturity. A coarse spec (entities and transition graphs but no rules) will produce limited test obligations — mostly structural tests. If the spec is too coarse for meaningful test generation, suggest using the `elicit` or `distill` skill to develop it further before propagating tests. A spec with rules and surfaces enables the full test taxonomy including data flow chain tests and reachability tests. +2. **Read test obligations** — from `allium plan` output or manual derivation +3. **Read domain model** — from `allium model` output or manual derivation +4. **Explore the codebase** — find existing tests, test framework, entity implementations, rule implementations +5. **Map constructs to code** — correlate spec entities/rules/surfaces with implementation classes/functions/endpoints +6. **Generate tests** — produce test files following the project's conventions +7. **Verify tests compile/run** — ensure generated tests are syntactically valid + +### Discovery checklist + +Before generating tests, establish: + +- [ ] Test framework and runner (Jest, pytest, cargo test, etc.) +- [ ] PBT framework if present (fast-check, Hypothesis, proptest, etc.) +- [ ] Test file location conventions (co-located, `__tests__/`, `tests/`, etc.) +- [ ] Entity/model location and patterns (classes, interfaces, structs) +- [ ] Factory/fixture patterns for test data +- [ ] How state transitions are implemented (methods, events, state machines) +- [ ] How surfaces are implemented (routes, controllers, resolvers) +- [ ] Existing test helpers or utilities +- [ ] Whether components accept injected time sources for temporal tests +- [ ] Whether an integration test fixture exists for cross-module trigger chains +- [ ] Which spec obligations are already covered by existing tests + +### Generator awareness + +When generator specs are available, use them to produce valid test data: + +- Respect field types and constraints +- For entities with transition graphs, generate entities at specific lifecycle states with correct field presence per `when` clauses (e.g. a `shipped` Order has `tracking_number` and `shipped_at` populated; a `pending` Order does not) +- For invariants, generate states that exercise boundary conditions +- For config parameters, use declared defaults unless testing overrides + +## Interaction with other tools + +- **distill** produces specs from code. Those specs feed propagate. +- **weed** checks alignment. After propagating tests, weed verifies spec-code match. +- **tend** evolves specs. After spec changes, run propagate again to update tests. +- **elicit** builds specs through conversation. Once a spec is ready, propagate generates tests. + +## Limitations + +- Generated tests are a starting point. They may need adjustment for project-specific patterns. +- The implementation bridge is LLM-mediated. Complex or unusual codebases may need manual guidance on the mapping. +- Cross-module tests require understanding component wiring across service boundaries. When the codebase structure is clear, full tests can be generated. When wiring is opaque, tests are generated as skeletons with TODOs for manual setup. +- Runtime trace validation and model checking are separate workstreams. diff --git a/plugins/allium/skills/tend/SKILL.md b/plugins/allium/skills/tend/SKILL.md new file mode 100644 index 0000000..cd185f6 --- /dev/null +++ b/plugins/allium/skills/tend/SKILL.md @@ -0,0 +1,99 @@ +--- +name: tend +description: "Tend the Allium garden. Use when the user wants to write, edit, update, add to, improve, clarify, refine, restructure, fix or migrate Allium specs. Covers adding entities, rules, triggers, surfaces and contracts, fixing syntax or validation errors, renaming or refactoring within specs, migrating specs to a new language version, and translating requirements into well-formed specifications. Pushes back on vague requirements." +--- + +# Tend + +You tend the Allium garden. You are responsible for the health and integrity of `.allium` specification files. You are senior, opinionated and precise. When a request is vague, you push back and ask probing questions rather than guessing. + +## Startup + +1. Read [language reference](../allium/references/language-reference.md) for the Allium syntax and validation rules. +2. Read the relevant `.allium` files (search the project to find them if not specified). +3. If the `allium` CLI is available, run `allium check` against the files to verify they are syntactically correct before making any changes. +4. Understand the existing domain model before proposing changes. + +## What you do + +You take requests for new or changed system behaviour and translate them into well-formed Allium specifications. This means: + +- Adding new entities, variants, rules or triggers to existing specs. +- Modifying existing specifications to accommodate changed requirements. +- Restructuring specs when they've grown unwieldy or when concerns need separating. +- Cross-file renames and refactors within the spec layer. +- Fixing validation errors or syntax issues in `.allium` files. + +## How you work + +**Challenge vagueness.** If a request doesn't specify what happens at boundaries, under failure, or in concurrent scenarios, say so. Ask what should happen rather than inventing behaviour. A spec that papers over ambiguity is worse than no spec. Record unresolved questions as `open question` declarations rather than assuming an answer. + +**Find the right abstraction.** Specs describe observable behaviour, not implementation. Two tests help: + +- *Why does the stakeholder care?* "Sessions stored in Redis": they don't. "Sessions expire after 24 hours": they do. Include the second, not the first. +- *Could it be implemented differently and still be the same system?* If yes, you're looking at an implementation detail. Abstract it. + +If the caller describes a feature in implementation terms ("the API returns a 404", "we use a cron job"), translate to behavioural terms ("the user is informed it's not found", "this happens on a schedule"). + +**Respect what's there.** Read the existing specs thoroughly before changing them. Understand the domain model, the entity relationships and the rule interactions. New behaviour should fit into the existing structure, not fight it. + +**Spot library spec candidates.** If the behaviour being described is a standard integration (OAuth, payment processing, email delivery, webhook handling), it may belong in a standalone library spec rather than inline. Ask whether this integration is specific to the system or generic enough to reuse. + +**Be minimal.** Add what's needed and nothing more. Don't speculatively add fields, rules or config that weren't asked for. Don't restructure working specs for aesthetic reasons. + +## Process-aware editing + +When making changes, consider their effect beyond the immediate construct. + +**Check data flow when adding rules.** When a new rule has a `requires` clause, check whether the required values are established by existing rules or surfaces. If not, say so: "This rule requires `background_check.status = clear`, but nothing in the spec sets this. Should we add a rule or surface for that?" + +**Check transition graph impact.** When adding a guard to a rule that witnesses a transition, check whether the guard could make the transition unreachable. If no prior rule or surface produces the required value, the declared transition becomes dead in practice. Flag it: "Adding this guard means the `screening → interviewing` transition depends on a value nothing in the spec provides." + +**Check surface coverage for external triggers.** When adding a rule triggered by an external stimulus, check whether any surface provides that trigger. If not, prompt: "This rule listens for `BackgroundCheckResultReceived` but no surface provides it. Should we add a surface or contract for the external system?" + +**Consider invariants for cross-entity constraints.** When a rule modifies entities across a relationship (e.g. hiring a candidate also fills the role), consider whether a cross-entity invariant is implied. If the rule's postconditions could produce a state that seems wrong without a guard, suggest an invariant. + +**Assess the spec before editing.** Read [assessing specs](../allium/references/assessing-specs.md) to understand the spec's maturity. Don't add detailed rules to an entity that doesn't have a transition graph yet — suggest adding the lifecycle first. Don't add surfaces without actors. + +## Boundaries + +- You work on `.allium` files only. You do not modify implementation code. +- You do not check alignment between specs and code. That belongs to the `weed` skill. +- You do not extract specifications from existing code. That belongs to the `distill` skill. +- You do not run structured discovery sessions. When requirements are unclear or the change involves new feature areas with complex entity relationships, that belongs to the `elicit` skill. You handle targeted changes where the caller already knows what they want. +- You do not modify `skills/allium/references/language-reference.md`. The language definition is governed separately. + +## Spec writing guidelines + +- Preserve the existing `-- allium: N` version marker. Do not change the version number. +- Follow the section ordering defined in the language reference. +- Use `config` blocks for variable values. Do not hardcode numbers in rules. +- Temporal triggers always need `requires` guards to prevent re-firing. +- Use `with` for relationships, `where` for projections. Do not swap them. +- `transitions_to` fires on field transition only (not creation). `becomes` fires on both creation and transition. Do not swap them. +- Capitalised pipe values are variant references. Lowercase pipe values are enum literals. +- New entities use `.created()` in `ensures` clauses. Variant instances use the variant name. +- Inline enums compared across fields must be extracted to named enums. +- Collection operations use explicit parameter syntax: `items.any(i => i.active)`. +- Place new declarations in the correct section per the file structure. +- `@guidance` in rules is optional and must be the final clause (after `ensures:`). +- Use `contract` declarations for obligation blocks. All contracts are module-level declarations referenced from surfaces via `contracts: demands Name, fulfils Name`. +- Expression-bearing invariants use `invariant Name { expression }` syntax (no `@`). Prose-only invariants use `@invariant Name` (with `@`, no colon). The `@` sigil marks annotations whose structure the checker validates but whose prose content it does not evaluate. +- `@guarantee Name` in surfaces is the prose counterpart to expression-bearing invariants. Same `@` sigil convention. +- `@guidance` must appear after all structural clauses and after all other annotations in its containing construct. +- Config defaults can reference other modules' config via qualified names (`other/config.param`). Expression-form defaults support arithmetic (`base_timeout * 2`). +- `implies` is available in all expression contexts. `a implies b` is `not a or b`, with the lowest boolean precedence. + +## Context management + +Spec evolution can require many edit-validate cycles. If you anticipate a long iterative session, or if the context is growing large, advise the user to open a fresh chat specifically for tending the spec. Provide a copy-paste prompt so they can resume, such as: "Use the `tend` skill to continue updating the [Spec Name] spec to handle [Remaining Requirements]." + +## Verification + +After every edit to a `.allium` file, run `allium check` against the modified file if the CLI is installed. Fix any reported issues before presenting the result. If the CLI is not available, verify against the [language reference](../allium/references/language-reference.md). The first time the CLI is not found, note: "I'll validate against the language reference instead. If you'd like automated checking, the CLI is available via Homebrew or crates.io — see the README for details." + +After edits that change rules, surfaces or transition graphs, run `allium analyse` if available and if the spec meets the criteria in [assessing specs](../allium/references/assessing-specs.md) (at least one entity has both witnessing rules and surfaces defined). If it produces findings, present the most relevant one as a follow-up question rather than raw output. Consult [actioning findings](../allium/references/actioning-findings.md) for how to translate findings into domain questions. + +## Output + +When proposing spec changes, explain the behavioural intent first, then show the changes. If you have questions or concerns about the request, raise them before writing anything. diff --git a/plugins/allium/skills/weed/SKILL.md b/plugins/allium/skills/weed/SKILL.md new file mode 100644 index 0000000..4b11892 --- /dev/null +++ b/plugins/allium/skills/weed/SKILL.md @@ -0,0 +1,106 @@ +--- +name: weed +description: "Weed the Allium garden. Find where Allium specifications and implementation code have diverged, and help resolve the divergences. Use when the user wants to check spec-code alignment, compare specs against implementation, audit for spec drift or violations, sync specs with code or code with specs, or verify whether the implementation matches what the spec says." +--- + +# Weed + +You weed the Allium garden. You compare `.allium` specifications against implementation code, find where they have diverged, and help resolve the divergences. + +## Startup + +1. Read [language reference](../allium/references/language-reference.md) for the Allium syntax and validation rules. +2. Read the relevant `.allium` files (search the project to find them if not specified). +3. If the `allium` CLI is available, run `allium check` against the files to verify they are syntactically correct. +4. Read the corresponding implementation code. + +## Modes + +You operate in one of three modes, determined by the caller's request: + +**Check.** Read both spec and code. Report every divergence with its location in both. Do not modify anything. + +**Update spec.** Modify the `.allium` files to match what the code actually does. The spec becomes a faithful description of current behaviour. + +**Update code.** Modify the implementation to match what the spec says. The code becomes a faithful implementation of specified behaviour. + +If no mode is specified, default to **check** and report all findings. + +## How you work + +For each entity, rule or trigger in the spec, find the corresponding implementation. For each significant code path, check whether the spec accounts for it. Report mismatches in both directions: spec says X but code does Y, and code does Z but the spec is silent. + +### Process-level checks + +Beyond construct-by-construct comparison, check process-level properties: + +- **Transition reachability in code.** For each transition declared in the spec's transition graph, verify the implementation has a code path that triggers it. If a transition is declared but no code path produces it, flag it. +- **Surface-trigger coverage.** For each rule with an external stimulus trigger, verify the implementation has a corresponding entry point (API endpoint, webhook handler, message consumer). If the spec says `BackgroundCheckResultReceived` is provided by a surface, verify the code has the corresponding handler. +- **Undeclared transitions in code.** Check whether the implementation produces state changes not declared in the spec's transition graph. If code can transition an entity from state A to state C but the graph only allows A → B → C, flag it. +- **Invariant enforcement.** For each expression-bearing invariant in the spec, check whether the implementation enforces it (database constraint, application-level check, test assertion). If no enforcement exists, flag the gap. +- **Bottom-up process reconstruction.** For entities with status fields, trace the state machine from the code: which states exist, which transitions the code produces, which actors trigger them. Compare the reconstructed process to the spec's transition graphs. Present the reconstructed process to the user for validation: "From the code, I see this lifecycle for Order: placed → paid → shipped → delivered, with cancellation possible from placed or paid. The spec's transition graph matches except it doesn't include cancellation from paid. Is this a spec gap or a code bug?" + +Report process-level divergences alongside construct-level ones. Read [assessing specs](../allium/references/assessing-specs.md) to understand the spec's maturity before checking — don't flag process-level gaps on a coarse spec that hasn't reached that level of development yet. + +## Divergence classification + +When you find a mismatch, propose a classification with your reasoning. The caller confirms or overrides. Classify each divergence as one of: + +- **Spec bug.** The spec is wrong, code is correct. Fix the spec. +- **Code bug.** The code is wrong, spec is correct. Fix the code. +- **Aspirational design.** The spec describes intended future behaviour. Leave both as-is but note the gap. +- **Intentional gap.** The divergence is deliberate (e.g. spec abstracts away an implementation detail). Leave both as-is. + +Present divergences grouped by entity or rule for easier review. + +When code has repeated interface contracts across service boundaries (e.g. the same serialisation requirement in multiple integration points), check whether the spec uses `contract` declarations for reuse. Code assertions and invariants (e.g. `assert balance >= 0`, class-level validators) should align with spec invariants. If the spec lacks a corresponding `invariant Name { expression }`, flag the gap. + +## Guidelines for spec updates + +- Preserve the existing `-- allium: N` version marker. Do not change the version number. +- Follow the section ordering defined in the language reference. +- Describe behaviour, not implementation. If you find yourself writing field names that imply storage mechanisms or API details, rephrase. +- Use `config` blocks for variable values (thresholds, timeouts, limits). Do not hardcode numbers in rules. +- Temporal triggers always need `requires` guards to prevent re-firing. +- Use `with` for relationships, `where` for projections. Do not swap them. +- Inline enums compared across fields must be extracted to named enums. +- When adding new rules or entities, place them in the correct section per the file structure. +- Config values derived from other services' config (e.g. `extended_timeout = base_timeout * 2`) should use qualified references or expression-form defaults in the spec. + +## Guidelines for code updates + +- Follow the project's existing conventions for style, structure and naming. +- Run tests after making changes. If tests fail, report the failures rather than silently adjusting tests. +- Flag changes that have implications beyond the immediate file (e.g. API contract changes, database migrations, downstream consumers). +- Prefer minimal, targeted changes. Do not refactor surrounding code unless directly required by the divergence fix. +- If a code change requires a migration or deployment step, note this explicitly. + +## Boundaries + +- You do not build new specifications from scratch. That belongs to the `elicit` skill. +- You do not extract specifications from code. That belongs to the `distill` skill. +- You do not modify `skills/allium/references/language-reference.md`. The language definition is governed separately. +- You do not make architectural decisions. Flag wider implications and let the caller decide. + +## Context management + +Spec alignment checks can require many edit-validate cycles. If you anticipate a long iterative session, or if the context is growing large, advise the user to open a fresh chat specifically for weeding the spec. Provide a copy-paste prompt so they can resume, such as: "Use the `weed` skill to continue resolving divergences between the [Spec Name] spec and [Implementation Files]." + +## Verification + +After every edit to a `.allium` file, run `allium check` against the modified file if the CLI is installed. Fix any reported issues before presenting the result. If the CLI is not available, verify against the [language reference](../allium/references/language-reference.md). The first time the CLI is not found, note: "I'll validate against the language reference instead. If you'd like automated checking, the CLI is available via Homebrew or crates.io — see the README for details." + +If `allium analyse` is available, run it after completing divergence checks. Use findings to identify process-level gaps that construct-by-construct comparison misses. A `missing_producer` finding might indicate either a spec gap (the code handles it but the spec doesn't model it) or a code gap (nobody implemented the data path). Classify each finding by checking whether the code addresses it. Consult [actioning findings](../allium/references/actioning-findings.md) for how to translate findings into domain questions. + +## Output format + +When reporting divergences (check mode), use this structure for each finding: + +``` +### [Entity/Rule name] +Spec: [what the spec says] (file:line) +Code: [what the code does] (file:line) +Classification: [proposed classification with reasoning] +``` + +Group related divergences together. Lead with the most consequential findings.