Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
d1c463f
feat: support items.oneOf/anyOf open catalogs in CALM pattern decisions
YoofiTT96 Aug 1, 2026
d890c1b
Merge remote-tracking branch 'origin/main' into feat/2859-items-based…
YoofiTT96 Aug 3, 2026
b7e1cac
fix: container precedence and all-optional generate output for patter…
YoofiTT96 Aug 3, 2026
15bc907
fix(shared): validate node/interface ids declared in items catalogs
YoofiTT96 Aug 3, 2026
44e169b
docs: remove internal patterns-options-and-decisions maintainer doc
YoofiTT96 Aug 3, 2026
a37a3dc
fix(shared): guard const-bearing arrays and close items-catalog test …
YoofiTT96 Aug 3, 2026
19dbb03
Merge branch 'main' into feat/2859-items-based-decision-support
YoofiTT96 Aug 5, 2026
742f3b6
Merge branch 'main' into feat/2859-items-based-decision-support
YoofiTT96 Aug 5, 2026
a36de7e
Merge branch 'main' into feat/2859-items-based-decision-support
YoofiTT96 Aug 6, 2026
fd5d548
fix: guard relationships-less patterns and classify catalog-only patt…
YoofiTT96 Aug 6, 2026
d2c3e4d
feat(shared): surface allOf-dropped items catalogs at debug level
YoofiTT96 Aug 6, 2026
2d3d592
refactor(calm-hub-ui): dedupe pattern schema traversal helpers
YoofiTT96 Aug 8, 2026
72e8679
Merge branch 'main' into feat/2859-items-based-decision-support
YoofiTT96 Aug 8, 2026
d6bdc14
Merge branch 'main' into feat/2859-items-based-decision-support
YoofiTT96 Aug 9, 2026
846e377
fix(shared): reject a pattern decision holder declared inside an item…
YoofiTT96 Aug 16, 2026
ed08b0f
fix(shared): warn on allOf key discards instead of a silent catalog-o…
YoofiTT96 Aug 16, 2026
9a2e202
refactor: extract a shared pattern-array reader into calm-models
YoofiTT96 Aug 16, 2026
e262bff
Merge remote-tracking branch 'origin/main' into feat/2859-items-based…
YoofiTT96 Aug 16, 2026
54309b7
style(shared): apply object-curly-spacing to spectral pattern specs
YoofiTT96 Aug 16, 2026
8c453b9
Merge branch 'main' into feat/2859-items-based-decision-support
YoofiTT96 Aug 17, 2026
071eb9d
Merge remote-tracking branch 'origin/main' into feat/2859-items-based…
YoofiTT96 Aug 22, 2026
3c35c11
fix(shared): detect duplicate and unreachable pattern candidates
YoofiTT96 Aug 22, 2026
5fcb9f8
fix(shared): reject a generate answer that names an unreachable candi…
YoofiTT96 Aug 22, 2026
e40ecea
fix(calm-hub-ui): key decision groups per decision, not per declarati…
YoofiTT96 Aug 22, 2026
a9748de
test: pin decision agreement between generate and the visualiser
YoofiTT96 Aug 22, 2026
54f7412
docs: record how pattern decisions are read across the three surfaces
YoofiTT96 Aug 22, 2026
c975cb2
fix(shared): consolidate listCandidates/listSelectableCandidates into…
YoofiTT96 Aug 24, 2026
9fda8e9
fix(shared): correct catalog-single-choice-keyword's message for pref…
YoofiTT96 Aug 24, 2026
5b4827a
docs: correct unreferenced-candidate warning claim for prefixItems slots
YoofiTT96 Aug 24, 2026
4a67258
refactor(calm-hub-ui): use readChoiceBlock for prefixItems slots, mat…
YoofiTT96 Aug 24, 2026
356ed24
fix(shared): validate every answer of a multi-select decision, not ju…
YoofiTT96 Aug 24, 2026
8fa43a8
test: add round-trip baselines for the conference-signup gap and item…
YoofiTT96 Aug 24, 2026
759d08e
docs: correct getPatternArray's allOf precedence comment
YoofiTT96 Aug 24, 2026
245f5e4
fix: stop getPatternArray composing prefixItems and items from differ…
YoofiTT96 Aug 25, 2026
11bcc23
Merge branch 'main' into feat/2859-items-based-decision-support
YoofiTT96 Aug 25, 2026
38a0ce0
docs(shared): update Pattern Decisions for the getPatternArray fix, d…
YoofiTT96 Aug 25, 2026
67c6966
docs(shared): trim redundant restatements in Pattern Decisions
YoofiTT96 Aug 25, 2026
e46bb05
fix(calm-models): make the pattern differ see items-catalog candidates
YoofiTT96 Aug 25, 2026
cca2e10
test(calm-hub-ui): prove the Hub diff no longer strands new catalog n…
YoofiTT96 Aug 25, 2026
3195b56
fix(vscode): pattern preview now includes items-catalog candidates
YoofiTT96 Aug 25, 2026
465a5c8
docs(cli): correct the round-trip baseline's misattributed cause
YoofiTT96 Aug 25, 2026
6e393df
fix(shared): drop a decision holder with zero selections instead of a…
YoofiTT96 Aug 25, 2026
b375988
docs(calm-hub-ui): fix stale comment on decision-group folding
YoofiTT96 Aug 25, 2026
1c8c59e
fix(shared): log discarded allOf-merge keys at debug, not warn
YoofiTT96 Aug 25, 2026
c7ca4e8
refactor(calm-models): rename pattern readers toward the question the…
YoofiTT96 Aug 25, 2026
3085cda
docs(shared): add PATTERN-DECISIONS.md, trim AGENTS.md to a pointer
YoofiTT96 Aug 25, 2026
ed4474a
docs(shared): make Pattern Decisions a durable reference
YoofiTT96 Aug 26, 2026
0849d40
refactor(calm-models): move listNodeInterfaces beside the candidate r…
YoofiTT96 Aug 26, 2026
bb3feda
docs(shared): scope the allOf note to what the code does
YoofiTT96 Aug 26, 2026
3bf62db
docs(shared): say which surface uses each allOf reader, and why they …
YoofiTT96 Aug 26, 2026
d4c85a5
Merge branch 'main' into feat/2859-items-based-decision-support
YoofiTT96 Aug 26, 2026
41c74fc
docs(shared): correct four claims found by probing the doc
YoofiTT96 Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 151 additions & 3 deletions calm-ai/tools/pattern-creation.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,148 @@ Patterns use JSON schema constructs to provide choices and options:
}
```

### Optional Nodes with an `items` Catalog (zero or more)

`prefixItems` describes fixed array positions: a slot is always present, and a `oneOf`/`anyOf` inside it chooses *which kind* of node fills that position. Use it when a node must exist and you are only choosing its type.

When you instead want an **open catalog** of optional nodes — "include any combination of these, including none" — declare the candidates under `items` (which applies to every array entry) rather than `prefixItems` (which pins entries to positions). Mandatory nodes go in `prefixItems`; the optional catalog goes in `items`:

```json
{
"properties": {
"nodes": {
"type": "array",
"minItems": 2,
"prefixItems": [
{
"$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node",
"type": "object",
"properties": {
"unique-id": { "const": "webapp" },
"name": { "const": "Web Application" },
"node-type": { "const": "webclient" }
}
},
{
"$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node",
"type": "object",
"properties": {
"unique-id": { "const": "database" },
"name": { "const": "Database" },
"node-type": { "const": "database" }
}
}
],
"items": {
"oneOf": [
{
"$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node",
"type": "object",
"properties": {
"unique-id": { "const": "cache" },
"name": { "const": "Cache" },
"node-type": { "const": "service" }
}
},
{
"$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node",
"type": "object",
"properties": {
"unique-id": { "const": "queue" },
"name": { "const": "Message Queue" },
"node-type": { "const": "service" }
}
}
]
}
}
}
}
```

Here `webapp` and `database` are always present, while `cache` and `queue` form an optional catalog: an instantiated architecture may include neither, either, or both.

A catalog on its own does nothing. Two different kinds of object are involved, and it is worth being precise about which is which:

- A **candidate** is a concrete node or relationship that may or may not end up in the generated architecture. Candidates are what an `items` catalog holds. Relationship candidates can use an `items` catalog exactly as node candidates do.
- A **decision holder** is a relationship carrying `relationship-type.options`. It is not part of the architecture being described — it asks the user a question and lists the choice bundles that answer it. Each bundle names candidates by `unique-id`.

A candidate is included in the output only when a chosen bundle names its `unique-id`. So every catalog needs a decision holder pointing at it, and **a decision holder must be declared in `properties.relationships.prefixItems`** — never inside an `items` catalog itself. A holder is the mechanism that drives generation, so it must always be present; putting it in a catalog makes the question itself optional, and `calm generate` will not offer it.

### `oneOf` and `anyOf` mean different things in different places

The same two keywords appear in three positions and do three different jobs. Getting this wrong is the commonest authoring mistake with catalogs.

**Inside the holder's `options` — this is the cardinality.** Use `anyOf` for a zero-or-more catalog (the user may pick any combination, including none) and `oneOf` where exactly one candidate must be chosen. This is the only place that controls how many candidates the user may select.

**Inside a `prefixItems` slot — this picks which node fills one fixed position.** The slot always exists; the alternatives compete for it.

**Inside `items` — neither.** The keyword there constrains what each individual array *entry* may look like, not how many entries there are. `items: { "oneOf": [cache, queue] }` reads as "each entry must be exactly one of cache or queue" — an architecture containing *both* is perfectly valid, because each entry independently matches exactly one candidate. "Zero or more" comes from `items` itself (plus `minItems`/`maxItems`), and how many are actually selected comes from the holder.

Because every candidate pins its `unique-id` with a `const`, an entry can match at most one candidate schema, so `oneOf` and `anyOf` accept exactly the same architectures here. **Use `oneOf`** — it is the accurate assertion and matches the example above.

**Never declare both `oneOf` and `anyOf` on one `items` block.** Only the `oneOf` list is read. Candidates under `anyOf` are silently dropped: `calm generate` will still *offer* them if a decision names them, then discard your answer without an error, and they will not appear in the diagram either.

The holder that drives the catalog above looks like this:

```json
{
"properties": {
"relationships": {
"type": "array",
"prefixItems": [
{
"$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship",
"type": "object",
"properties": {
"unique-id": { "const": "optional-components" },
"description": { "const": "Which optional components do you want?" },
"relationship-type": {
"type": "object",
"properties": {
"options": {
"type": "array",
"prefixItems": [
{
"anyOf": [
{
"$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/decision",
"type": "object",
"properties": {
"description": { "const": "Add a cache" },
"nodes": { "const": ["cache"] },
"relationships": { "const": [] }
}
},
{
"$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/decision",
"type": "object",
"properties": {
"description": { "const": "Add a message queue" },
"nodes": { "const": ["queue"] },
"relationships": { "const": [] }
}
}
]
}
]
}
}
}
}
}
]
}
}
}
```

Guidance:

- Keep every candidate that a single decision references within one declaration site. A decision whose candidates are split between a `prefixItems` slot and an `items` catalog, or spread across nodes with inconsistent container membership, is a pattern smell — model the choice at one consistent level.
- Declare every decision holder (a relationship with `relationship-type.options`) in `properties.relationships.prefixItems`. A catalog with no holder pointing at it can never be selected from, and `calm validate` warns that its candidates are unreferenced.
- Duplicate `unique-id`s inside an `items` catalog are rejected by `calm validate`, and a catalog node that no relationship or decision references produces a warning — the same check a plain `prefixItems` entry gets. A candidate declared as a `prefixItems[i].oneOf`/`anyOf` alternative does not get this warning — the rule reaches a plain `prefixItems` entry and an `items` catalog member, but not inside a slot's alternatives.

### Relationship Options with Decision Points

```json
Expand Down Expand Up @@ -618,7 +760,9 @@ Always use specific interface schema references:

### Array Handling

- Use `prefixItems` to define specific array positions
- Use `prefixItems` to define specific array positions (fixed slots)
- Use `items` to define an open catalog of optional entries (zero or more, any combination); combine with `prefixItems` for mandatory-plus-optional arrays
- Inside `items`, use `oneOf` (not both `oneOf` and `anyOf` — see above). The keyword constrains each entry's shape; it does **not** limit how many entries the array may hold
- Use `minItems`/`maxItems` to constrain array sizes
- Each array item should reference base schema + add constraints

Expand All @@ -643,13 +787,16 @@ The CLI will prompt for choices when encountering `anyOf`/`oneOf` options, or yo
- `const` - Fixed values that cannot be changed
- `enum` - List of allowed values
- `minItems`/`maxItems` - Array size constraints
- `prefixItems` - Define specific array items
- `prefixItems` - Define specific array items by position (fixed slots)
- `items` - Define the rule every array entry must satisfy; with a `oneOf`/`anyOf` inside it, an open catalog of optional entries (zero or more)

### Option Constructs

- `anyOf` - One or more options can be true
- `oneOf` - Exactly one option must be true
- `allOf` - All conditions must be true
- Placed inside a `prefixItems` slot, `oneOf`/`anyOf` chooses which node fills that fixed position; placed inside `items`, they define the optional catalog an entry may be drawn from
- Do not split a single property's definition (e.g. `properties.nodes`) across separate `allOf` branches — the merge is shallow, so the later branch's definition replaces the earlier one wholesale rather than combining them. `allOf` is unsupported for `relationships`: decision holders are only discovered in `properties.relationships.prefixItems` on the raw pattern, before `allOf` is flattened

### Schema References

Expand All @@ -666,7 +813,8 @@ The CLI will prompt for choices when encountering `anyOf`/`oneOf` options, or yo
4. Relationship definitions must use `$ref` to core relationship schema
5. Use `const` for fixed values, `anyOf`/`oneOf` for options
6. All constraint properties must be valid JSON schema constructs
7. Pattern should be testable with `calm validate -p <pattern-file>`
7. `unique-id`s must be unique across the whole pattern, including inside `items.oneOf`/`anyOf` catalogs
8. Pattern should be testable with `calm validate -p <pattern-file>`

## Best Practices

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { DiffResult } from '@finos/calm-models/diff';
import { DiffResult, diffPatterns } from '@finos/calm-models/diff';
import { parsePatternDataWithDiff } from './patternDiffTransformer.js';

const pattern = {
Expand Down Expand Up @@ -58,4 +58,43 @@ describe('patternDiffTransformer', () => {
expect(added?.style).toMatchObject({ boxShadow: '0 0 0 3px #16a34a' });
expect(result.nodes.find((n) => n.id === 'api-gateway')?.data.diffStatus).toBe('unchanged');
});

it('marks a newly added items-catalog candidate as added, using the real diffPatterns output', () => {
const patternBeforeCatalog = {
properties: {
nodes: {
type: 'array',
prefixItems: [
{ properties: { 'unique-id': { const: 'api-gateway' }, name: { const: 'API Gateway' }, 'node-type': { const: 'service' } } },
],
},
relationships: { type: 'array', prefixItems: [] },
},
};
const patternWithCatalog = {
properties: {
nodes: {
type: 'array',
prefixItems: [
{ properties: { 'unique-id': { const: 'api-gateway' }, name: { const: 'API Gateway' }, 'node-type': { const: 'service' } } },
],
items: {
anyOf: [
{ properties: { 'unique-id': { const: 'redis' }, name: { const: 'Redis' }, 'node-type': { const: 'database' } } },
],
},
},
relationships: { type: 'array', prefixItems: [] },
},
};

// Not a hand-built DiffResult - this is the real diffPatterns output, so the
// test proves the pattern-diff reader and the graph transformer agree on catalog
// candidates, not just that applyDiffStatus honours whatever it's given.
const diffResult = diffPatterns(patternBeforeCatalog, patternWithCatalog);
const result = parsePatternDataWithDiff(patternWithCatalog, diffResult, false);
const redis = result.nodes.find((n) => n.id === 'redis');
expect(redis).toBeDefined();
expect(redis?.data.diffStatus).toBe('added');
});
});
22 changes: 22 additions & 0 deletions calm-hub-ui/src/visualizer/components/drawer/Drawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,28 @@ describe('Drawer', () => {
expect(screen.getByTestId('reactflow-visualizer')).toBeInTheDocument();
});

it('classifies a catalog-only pattern (nodes via items, no prefixItems) dropped as a file as a pattern', async () => {
render(<Drawer />);

// A pattern whose nodes are declared solely through an `items` catalog has no
// `prefixItems`. It must still be routed to the PatternVisualizer, not the
// architecture ReactFlowVisualizer, on the file-upload path.
await act(async () => {
await mockDropzone.onDrop?.([
fakeFile(
JSON.stringify({
properties: {
nodes: { items: { oneOf: [{ properties: { 'unique-id': { const: 'cache' } } }] } },
},
})
),
]);
});

expect(screen.getByTestId('pattern-visualizer')).toBeInTheDocument();
expect(screen.queryByTestId('reactflow-visualizer')).not.toBeInTheDocument();
});

// Regression coverage for the dropped-file stale-layout bug: `defaultLayout`/
// `layoutEpoch` describe the currently-*loaded* resource's saved server layout,
// so they must collapse alongside `viewportKey` once a file is dropped —
Expand Down
10 changes: 8 additions & 2 deletions calm-hub-ui/src/visualizer/components/drawer/Drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,19 @@ import type { DrawerProps, Flow, Control, Decorator } from '../../contracts/cont

/**
* Detect whether JSON data is a CALM pattern (JSON Schema) or an architecture instance.
* Patterns have properties.nodes.prefixItems; architectures have nodes directly.
* A pattern declares its nodes as a JSON Schema array — either as positional
* `prefixItems` slots or as an open `items` catalog (or both); an architecture has
* `nodes` directly as a plain array of instances. A catalog-only pattern has no
* `prefixItems`, so `items` must be accepted too, otherwise it would be misclassified
* as an architecture on the file-upload path (the Hub path is saved separately by the
* `calmType === 'Patterns'` fallback below).
*/
function isPatternData(data: unknown): boolean {
if (!data || typeof data !== 'object') return false;
const obj = data as Record<string, unknown>;
const props = obj['properties'] as Record<string, unknown> | undefined;
return !!(props?.['nodes'] && typeof props['nodes'] === 'object' && (props['nodes'] as Record<string, unknown>)['prefixItems']);
const nodes = props?.['nodes'] as Record<string, unknown> | undefined;
return !!(nodes && typeof nodes === 'object' && (nodes['prefixItems'] || nodes['items']));
}

/**
Expand Down
Loading
Loading