Skip to content

feat: support items-based (zero-or-more) candidate catalogs in pattern decisions - #2932

Open
YoofiTT96 wants to merge 52 commits into
finos:mainfrom
YoofiTT96:feat/2859-items-based-decision-support
Open

feat: support items-based (zero-or-more) candidate catalogs in pattern decisions#2932
YoofiTT96 wants to merge 52 commits into
finos:mainfrom
YoofiTT96:feat/2859-items-based-decision-support

Conversation

@YoofiTT96

@YoofiTT96 YoofiTT96 commented Aug 3, 2026

Copy link
Copy Markdown
Member

Description

Lets a CALM pattern declare "zero or more of these candidates" through a JSON Schema items.oneOf/items.anyOf open catalog, alongside the existing prefixItems positional slots. No meta-schema change is needed — items is standard JSON Schema.

Closes #2859.

The reason this touches so much is that three surfaces read pattern decisions independently, and all three were prefixItems-only: validation (shared/src/spectral), generation (shared/src/commands/generate) and visualisation (calm-hub-ui). Nothing made them agree, which is where most of the defects below came from. There is now a fixture contract in test_fixtures/decision-agreement/ that both shared and calm-hub-ui assert against, so the two cannot drift apart silently again.

Decisions worth knowing about

A decision holder must live in properties.relationships.prefixItems. Candidates belong in the catalog; the decision that selects them does not. A holder placed in a catalog instead makes the decision itself optional, which is incoherent, so calm validate now rejects it — a new pattern rule, not a generate-time check. calm generate never validates, so a user who only generates still gets silence: the decision is simply never offered, same as before. The rule is the fix; enforcement is one command away from where the mistake was made, and that's documented, not accidental.

calm generate never validates, so it carries its own guard. Choosing a candidate that selection cannot reach now throws instead of quietly producing an architecture missing what you asked for.

A candidate can be drawn in one box only. When two decisions name the same candidate, the first keeps it and the second still renders with what is left. Boxing one node twice needs the nesting rework in #2933.

A decision whose every candidate shares one container loses its box, and its prompt with it. Container membership beats decision-group membership when a node is both, so a oneOf/anyOf between two services deployed into the same container renders no box at all — the choice becomes invisible in the diagram, not just unstyled. Deliberate and directly tested, and the flat interim #2933's nesting rework replaces: nesting the box inside the container is the real fix, not something this PR attempts.

allOf for nodes and relationships remains unsupported. Three parts of the system read it differently, all deliberately. Documented in shared/AGENTS.md; convergence is separate work.

The CLI and the visualiser already disagreed on a decision holder declaring both oneOf and anyOf, before this PR. Confirmed against main: the CLI offers every choice from both keywords, the visualiser draws only the oneOf half. Neither reader was touched here beyond the visualiser's hand-rolled check being swapped for the shared readChoiceBlock — same rule, same result. Not this PR's to resolve; documented in shared/AGENTS.md's "Still duplicated" table.

Behaviour changes

Change Before
duplicate unique-id in an items catalog errors passed silently
duplicate unique-id in a oneOf/anyOf slot errors invisible
interface checks see every alternative in a slot first alternative only; one alternative could borrow another's interfaces
duplicate-id errors carry a source position reported at line 0
a decision naming an unreachable candidate errors passed
a block declaring both oneOf and anyOf warns silent, and the anyOf candidates were unreachable
calm generate rejects an unreachable answer discarded the answer, generated without it
instantiate emits [] for an array with no prefixItems {}, invalid where an array is required
junit output lists two more rules
two decisions over one catalog draw two boxes one box, one prompt lost
a decision between containers keeps its box vanished entirely
calm validate checks every selected answer of a multi-answer (anyOf) decision only the first recorded answer was applied to the pattern before validating; a second selected candidate went unchecked

The dual-keyword row above is coverage plus a message fix, not a bug fix: the rule already caught this on a prefixItems slot before this PR touched it, and catching it on an items catalog too was the intended expansion — only its wording said "items catalog" for both cases, so a prefixItems finding read as a false description of where the problem was.

The multi-answer row above is a strictness increase, not just new coverage: an architecture with a bad second answer used to validate clean and will now fail. Worth calling out in the release note alongside the pattern-side changes, since it can surface on an architecture nobody has touched.

Nothing in this repo uses items catalogs today, so nothing shipped regresses. Externally authored patterns carrying any of the newly detected faults will start failing calm validate. Worth a release note on the shared/CLI bump.

Fixed since first review

Two independent review passes surfaced defects this PR introduced after it was opened; all are fixed on this branch now. (The multi-answer validation fix is the same class of finding, but it earns its own row in the behaviour table above, not a line here — it's a strictness increase worth the extra visibility.)

  • The pattern array reader could compose a nodes/relationships array from two different allOf branches, describing an array no single declaration site actually contains.
  • calm diff, the CALM Hub's visual pattern diff, and the VSCode pattern preview were all blind to items-catalog candidates: a catalog addition or removal produced no diff at all, and a catalog-only pattern previewed as empty. All three now see catalog candidates the same way generation and validation already did.
  • Leaving one decision fully unanswered while answering another broke calm validate with an internal schema-compilation error, because the narrowed pattern ended up with an empty prefixItems, which is not legal JSON Schema. "Pick none from this catalog" is this feature's own advertised case, so the decision holder is now dropped instead of narrowed to empty.

Deferred

Nesting decision boxes and containers stays in #2933, along with the case where two decisions share every candidate. The container-precedence behaviour here is the flat interim that #2933 supersedes.

Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📚 Documentation update
  • 🎨 Code style/formatting changes
  • ♻️ Refactoring (no functional changes)
  • ⚡ Performance improvements
  • ✅ Test additions or updates
  • 🔧 Chore (maintenance, dependencies, CI, etc.)

Affected Components

  • CLI (cli/)
  • Schema (calm/)
  • CALM AI (calm-ai/)
  • CALM Hub (calm-hub/)
  • CALM Hub UI (calm-hub-ui/)
  • CALM Server (calm-server/)
  • CALM Widgets (calm-widgets/)
  • Documentation (docs/)
  • Shared (shared/)
  • VS Code Extension (calm-plugins/vscode/)
  • Dependencies
  • CI/CD

Testing

  • I have tested my changes locally
  • I have added/updated unit tests
  • All existing tests pass

Checklist

  • My commits follow the conventional commit format
  • I have updated documentation if necessary
  • I have added tests for my changes (if applicable)
  • My changes follow the project's coding standards

Extends pattern-based decision support beyond prefixItems positional
slots (pick exactly one) to also cover items.oneOf/items.anyOf open
catalogs (pick zero or more), across validation, visualisation, and
generate/instantiate.

- shared/spectral: ids-are-unique and pattern-nodes-must-be-referenced
  now also scan items.oneOf/anyOf candidates (nodes, relationships,
  and interfaces nested in catalog nodes).
- calm-hub-ui: patternTransformer extracts items catalog candidates as
  decision groups, and folds an options relationship's referenced ids
  into a single decision group regardless of whether they come from a
  prefixItems slot or the items catalog, creating a new group when
  none of the referenced ids already belong to one. Dangling id
  references still render nothing.
- shared/generate: selectChoices narrows a selected items catalog down
  into prefixItems so instantiate materializes chosen catalog nodes
  without needing separate items handling.
…n decisions

Follow-up fixes and documentation on top of items-based decision support (finos#2859):

- calm-hub-ui: a node that is both a decision candidate and a container child now
  renders inside its container rather than the choice box, and a decision box left
  empty by that reparenting is suppressed so no empty box is drawn.
- shared/generate: an array declared only through an items catalog and generated
  with no selection now instantiates as [] instead of {}, fixing malformed output.
- docs: add PATTERNS_OPTIONS_AND_DECISIONS.md explaining how validation, generation
  and visualisation each consume pattern decisions independently; linked from the
  shared and calm-hub-ui AGENTS.md guides.
- calm-ai: document the items open-catalog construct in the pattern creation guide.

Refs finos#2859
Independent review found two validation call sites that were still prefixItems-only
and had been missed when items.oneOf/anyOf support was added, contradicting the
feature (finos#2859):

- node-id-exists.ts (backs the connects, deployed-in/composed-of, actor/container,
  and decision nodes.const rules) now also resolves ids against nodes.items.oneOf/
  anyOf, so a relationship or decision referencing a catalog-only node no longer
  raises a false "does not refer to an existing node" error.
- interface-id-exists-on-node.ts now includes items-catalog nodes in its node lookup,
  so interface checks on catalog nodes run instead of being silently skipped.
- options.ts flattenCalmItems only strips items when it is a oneOf/anyOf catalog,
  leaving an unrelated plain/false items schema untouched.

Also document, in PATTERNS_OPTIONS_AND_DECISIONS.md, the full set of validation
helpers updated and a known rendering limitation (two decisions sharing one items
catalog merge into one box).

Refs finos#2859
Removes docs/PATTERNS_OPTIONS_AND_DECISIONS.md and the two AGENTS.md pointers to it.
An implementation-internals document is a maintenance liability: it must be updated
on every change to decision handling or it silently misleads. The load-bearing
context does not depend on it — the known rendering limitation is tracked in finos#2933,
the validation/rendering behaviour changes are in the PR description, fixed
behaviours are pinned by tests, and the allOf-merge caveat is a code comment.

Refs finos#2859
…gaps

Addresses nits from an independent review of finos#2932:

- instantiate.ts: check const before the bare-array fallback so an array schema
  carrying a const materializes its value instead of being emptied to []. Latent
  ordering issue introduced with the earlier all-optional-array fix; not reachable
  by today's node/relationship arrays but fixed for safety, with a regression test.
- ids-are-unique.spec.ts: add the missing relationships items.oneOf duplicate case
  (the code path existed but was untested).
- patternTransformer.test.ts: cover an items catalog with no options relationship
  (extracted-but-never-folded group renders as a labelled box with no prompt).
- ids-are-unique.ts: add the missing trailing newline.

Refs finos#2859
@YoofiTT96
YoofiTT96 marked this pull request as ready for review August 3, 2026 21:53

@markscott-ms markscott-ms left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Adds items.oneOf/items.anyOf open-catalog support across the three surfaces that read pattern decisions independently. The core approach — narrowing a selected catalog into prefixItems inside selectChoices so instantiate() needs no new concept — is the right call and keeps the generation change small. Good comments explaining why, sensible decomposition (extractNodeDecisionGroup, extractRelationshipDecisionGroup, foldOptionsMetadataIntoDecisionGroups), and an honest write-up of the behaviour changes.

Most of my findings are gaps rather than defects in what's written; they're left inline. Two things that don't anchor to a diff line:

isPatternData in calm-hub-ui/src/visualizer/components/drawer/Drawer.tsx:21 wasn't updated

It requires properties.nodes.prefixItems to classify JSON as a pattern. A catalog-only pattern — nodes declared solely through items, a shape this PR explicitly supports and covers in patternTransformer.test.ts ("creates a decision group box for an items catalog with no options relationship") — fails that check and gets routed as an architecture. Hub-sourced patterns are saved by the calmType === 'Patterns' fallback on line 80; the file-upload path isn't. One-line fix: accept items as well as prefixItems.

isDefinedInOneOfOrAnyOf deliberately needing no change is worth a sentence in the PR body

It's the one decision-related pattern rule left untouched, so a reader will wonder. It only fires when an id is present in plain prefixItems and absent from any oneOf/anyOf, so a catalog-declared id passes rather than false-positives. Correct as-is, just non-obvious.

Verified as fine

  • nodeHasRelationship's $..relationship-type..*@string() already sweeps catalog relationships and decision nodes.const arrays, so the widened given in rules-pattern.ts behaves as the PR body claims.
  • Ordering in flattenCalmItems ([...flattenedPrefixItems, ...selectedCatalogItems]) is correct against draft 2020-12: selected candidates land at indices >= the original prefixItems length, exactly where the original pattern's items applies, so the generated architecture still validates against the unmodified pattern.
  • The const-before-array reordering in instantiate.ts is right, and the dedicated test pinning that branch order is a nice touch.

Test coverage

Solid on the paths that matter, with two new branches uncovered — see the inline notes on the two test helpers. Also worth a test for the multi-decision-over-one-catalog case, whichever way it's resolved, since it pins the intended semantics.

Risk

Low. The stricter validation only affects a construct nothing in the repo uses, and is already flagged for a release note. The instantiate [] fallback is the only change touching pre-existing behaviour, and {} where an array was required was never valid output — a strict improvement.

Comment thread calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts Outdated
Comment thread shared/src/commands/generate/components/options.ts
Comment thread shared/src/commands/generate/components/options.spec.ts
Comment thread calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts Outdated
Comment thread shared/src/commands/generate/components/flatten-allof.ts Outdated
YoofiTT96 and others added 4 commits August 6, 2026 14:32
…erns

Address review findings on the items-catalog decision-support PR.

- generate: guard the relationships access in flattenOptionsRelationships so
  a pattern whose nodes are declared entirely through an items catalog (and
  that carries no relationships property) no longer throws during selectChoices.
- calm-hub-ui: widen isPatternData to accept nodes.items as well as
  nodes.prefixItems, so a catalog-only pattern dropped as a file is routed to
  the PatternVisualizer instead of being misclassified as an architecture.
- Close test gaps: relationships items-catalog narrowing, a nodes-only catalog
  with no relationships property, the items.anyOf node catalog UI path, the
  dashed rel-decision-items edge path, and the catalog-only file classification.
When two allOf branches each declare an items open-catalog under the same
array property (e.g. both define properties.nodes.items), flattenAllOf's
shallow properties merge makes the later branch's catalog win and silently
drops the earlier one. Add logDroppedItemsCatalogs to log this at debug level
so it is discoverable under --verbose rather than only from a code comment.

The detection lives at the properties-merge site where the loss actually
occurs, not the top-level items else-branch (which a per-array catalog never
reaches). Clarify that else-branch comment accordingly.
Behaviour-preserving cleanup of the items-catalog decision code.

- Extract getArrayKeyword(pattern, key, keyword) and reduce getPrefixItems and
  getItems to thin wrappers over it, removing the duplicated allOf-walking
  shape. The wrappers keep their exact return contracts ([] vs undefined, and
  the items:false closed-tuple case), so every call site is unchanged.
- Extract catalogAlternatives(items) returning { groupType, alternatives } | null
  and use it in both extractNodesFromPattern and extractRelationshipsFromPattern,
  removing the repeated Array.isArray(oneOf/anyOf) blocks.

No behaviour change: the full calm-hub-ui suite is byte-for-byte identical
(114 files, 1371 tests) before and after, typecheck and lint clean.
@markscott-ms

Copy link
Copy Markdown
Contributor

@YoofiTT96 let us know when this is good for re-review

… calm-models

Two copies of the candidate-listing walk existed: calm-models ignored allOf,
shared followed it through getPatternArray and reported a path the document
didn't contain. Nothing tested or relied on the divergent behaviour, and
allOf for nodes/relationships is already unsupported, so the copies are
unified in calm-models rather than reconciled - one walk, parameterised by
resolution ('all' for listCandidates, 'operative' for the new
listSelectableCandidates), matching the pattern readChoiceBlock already set.

shared/src/pattern-candidates.ts is deleted; its two consumers
(decision-references-selectable-candidate.ts and options.ts's
assertChoicesAreSelectable) now import from @finos/calm-models/pattern.
assertChoicesAreSelectable had no test coverage before this; added 7 cases.
Added 8 new calm-models tests for listSelectableCandidates.

catalog-decisions.spec.ts's allOf regression test called the guard directly
on a raw, unflattened pattern - a call shape with no real caller, since
runGenerate always flattens first. Fixed the test to flatten first, matching
actual usage; the real generate pipeline was never broken.
…ixItems slots

The rule's given already matched prefixItems slots as well as items
catalogs - both shapes hit the same underlying defect, oneOf silently
winning over anyOf - but the message and the rule's own description always
said "an items catalog declares both...", wrong wording for half its
matches. Made both shape-neutral. Added the missing prefixItems-trigger
test coverage; none existed despite the given already covering it.
The authoring guide claimed a catalog node with no reference produces a
warning "exactly as for prefixItems". True for a plain prefixItems entry,
not for a prefixItems[i].oneOf/anyOf alternative - pattern-nodes-must-be-
referenced never covered that shape, on main or after this PR. Corrected
the claim and pointed at the tracked follow-up.
…ching the catalog path

patternTransformer.ts hand-rolled hasOneOf/hasAnyOf for prefixItems slot
alternatives in three places, right alongside calls to the shared
readChoiceBlock for the items-catalog case. Same oneOf-wins-over-anyOf
precedence, same shape - readChoiceBlock already answers this question.

Pure refactor, no behaviour change for any valid input: verified the
calm-hub-ui test suite (1419 tests) passes identically before and after,
and diffed tsc --noEmit output before/after to confirm no new type errors.
readChoiceBlock's null guard is marginally more defensive than the hand-
rolled version for a null/undefined prefixItems entry, which the old code
would have thrown on - not reachable by any legal pattern.
…st the first

extractChoicesFromArchitecture indexed relationship-type.options[0], so a
zero-answer anyOf decision crashed the validator (reading .description off
undefined) and a two-or-more-answer decision silently validated only the
first, leaving every other selection completely unchecked.

Pre-existing on main, byte-identical, and unrelated to items catalogs:
anyOf-typed multi-answer decisions over plain prefixItems candidates are
documented on main (calm-ai/tools/pattern-creation.md, 'Providing Options
with anyOf/oneOf'), already enforced by
pattern-option-relationship-must-only-have-oneof-or-anyof-items, and trace
to issue finos#706. The CALM meta-schema places no length restriction on
options. Confirmed the real shape via the actual generate pipeline: one
options[] entry per selection, not one combined entry.

Fix: flatMap every options entry instead of indexing [0]. selectChoices
already handles multiple CalmChoices for the same decision correctly.
…s catalogs

Two related additions, both about the same problem: nothing tested a full
generate-then-validate cycle against a real pattern, which is why the
control-id gap and the finding-8 multi-answer bug both went unnoticed.

cli.e2e.spec.ts: pins the conference-signup pattern's known, pre-existing
failure (control-requirement-validation on control-id) as an explicit
baseline inside the existing 'Getting Started Verification' test. Root
cause: calm/getting-started/controls/permitted-connection-jdbc.config.json
declares control-id 'security-003'; the shared requirement schema it's
checked against demands 'security-002'. The sibling http config file - same
control, different protocol - correctly uses security-002, confirming this
is a copy-paste slip from when the jdbc variant was authored (both created
together in commit 4b8a718, June 2025), not an intentional difference.
Reproduces identically on main; the checked-in reference fixture inherits
the same wrong value. Comment says to delete the block once this is
actually fixed - it's a one-value data correction, not a design question.

items-catalog-round-trip.e2e.spec.ts: a new, clean baseline that actually
works, since no repository pattern uses items catalogs yet to point at
instead. Deliberately excludes control requirements so it's unaffected by
the conference-signup gap. Three cases, one shared fixture: a clean 2-select
happy path, a 0-select case (the crash half of finding 8's bug), and a
corrupt-the-second-selection case (the silent-validation-gap half). The
corruption case is the one with real discriminating power - verified
empirically against two different bugs (extractChoicesFromArchitecture's
original .map(...)[0] and a simulated equivalent in the sibling
flattenOptionsRelationship), confirming it fails for either and passes
again once each is reverted, so its protection isn't narrowly tied to one
specific code path.
@linux-foundation-easycla

linux-foundation-easycla Bot commented Aug 24, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

The TEMPORARY comment on readArrayKeyword described the allOf fallback
as if one branch won for the prefixItems/items pair. getPatternArray
actually calls it once per keyword, each search independent, so the
two keywords for the same property can resolve from different allOf
branches. Reworded to say so. Comment only, no behaviour change.
…ent allOf branches

getPatternArray resolved prefixItems and items independently, each with
its own allOf fallback search. A pattern could end up with prefixItems
from one branch and an items catalog from a different branch - an
array no single declaration site in the document actually describes.

Introduced by this PR (getPatternArray/pattern-reader.ts is new on
this branch; main never resolved items through allOf at all, so the
two-keyword composition wasn't reachable there). Fix: resolve one
container per property (root, else the first allOf branch that
declares either keyword) and read both keywords from it. No signature
or caller changes.

Pinned four cases in pattern-reader.spec.ts: the original repro, the
reverse direction (catalog on one source, prefixItems on another), a
split across two different allOf branches with an empty root, and the
existing container-skip precedent (root declares the property but
neither keyword).
@YoofiTT96
YoofiTT96 force-pushed the feat/2859-items-based-decision-support branch from beb0f25 to 245f5e4 Compare August 25, 2026 00:00
YoofiTT96 and others added 12 commits August 25, 2026 00:01
…rop line citations

getPatternArray's allOf table row still described the pre-fix
behaviour (independent per-keyword resolution) instead of the
single-branch-for-both-keywords fix, and the paragraph after it now
contradicted that row by claiming no reader reaches into allOf.
Corrected both.

Replaced four file:line citations with function names or, where the
specific expression was the point (the node-has-relationship.ts
JSONPath query), a quoted snippet instead. Line numbers drift on any
unrelated edit above them with nothing to catch it; three of the four
citations had already gone stale.
Three cuts, no new claims:
- opening two sentences duplicated pattern-creation.md's decision
  holder/candidate definitions near-verbatim; point at that file
  instead of re-deriving the concept, keep only the code-specific
  enforcement detail
- getPatternArray was described twice two lines apart (once
  generically, once with allOf detail in the very next table);
  the first mention now just points at the table row
- the allOf section stated 'unsupported' at open and close of the
  same subsection; kept only the clause after the second mention
  that adds something (the listCandidates removal didn't add allOf
  support), cut the restated half in front of it
pattern-diff.ts read prefixItems independently, with its own allOf
fallback, and never looked at items - so a catalog candidate added or
removed from a pattern produced no diff at all. calm diff --exit-code
is a documented CI gate for version bumps, so a breaking catalog change
could pass it silently.

getPrefixItems is replaced by getCandidateItems, delegating to
getPatternArray and feeding its catalog through the same
expandAlternatives step slot alternatives already go through.

Pinned with 4 new tests (add/remove/unchanged/instantiate). Full
calm-models suite 231/231, build clean, lint 0 errors.
…odes as unchanged

applyDiffStatus defaults every node to 'unchanged' and only moves it on
a unique-id match against DiffResult. Catalog candidates never appeared
there, so a newly added one was drawn (this PR's own transformer draws
catalog candidates) but coloured as unchanged - a false statement, not
an incomplete one.

No source change needed here: fixing pattern-diff.ts's own blind spot
(previous commit) fixes this as a direct consequence. This test proves
that with the real diffPatterns output, not a hand-built DiffResult -
confirmed red by reverting the previous commit and rebuilding, green
after restoring it.
instantiateFromPattern only ever read properties.<nodes|relationships>.
prefixItems, so a catalog-only pattern previewed as empty. It already
handled prefixItems[i].oneOf/anyOf decisions correctly - only the
catalog was unhandled.

An items catalog is structurally the same oneOf/anyOf block a decision
slot's alternatives are, so the new catalogEntry helper appends it to
the same list instantiateNode/instantiateRel already unwrap, rather
than adding a second code path.

No test file existed for this component; added one (3 tests, red
confirmed before the fix). Full vscode suite 158/158 + 1 pre-existing
todo, lint 0 errors.
The comment said calm generate never fetches the schema requirement-url
points at. That's not it: permitted-connection-jdbc.config.json
declares control-id security-003, but the requirement schema pins
security-002 - a one-value copy-paste slip (the sibling http config
correctly uses security-002). The commit that added this baseline said
so in its own message; only the inline comment was wrong, and it's the
one a maintainer would actually follow.
…n illegal empty prefixItems

An anyOf decision left unanswered (a checkbox with nothing checked) made
flattenOptionsRelationship write options.prefixItems: [] into the
narrowed pattern. An empty prefixItems is not a legal JSON Schema -
selectChoices's own docstring already forbids it elsewhere - so the next
schema compilation broke. calm generate itself didn't notice (the empty
array is fine as instance data), but calm validate compiles the same
narrowed pattern as a schema, and that's where it surfaced:
'options/prefixItems must NOT have fewer than 1 items'.

Not a regression: on main this same input crashed a different way
(options[0] read off undefined). But 'pick none from this catalog' is
the feature's own advertised case, so leaving it broken undercuts the
headline.

Fix: a decision resolved to nothing chosen has nothing to materialize,
so flattenOptionsRelationship now drops the holder relationship
entirely rather than narrowing it to empty.

Reproduced live against a real repository pattern (multiple-choices
options-prototype.pattern.json) before writing the fix - confirmed the
exact reported error, confirmed generate succeeds silently while
validate is the one that breaks, confirmed the fix removes exactly that
error and nothing else (a separate, pre-existing schema-compile issue
in that same prototype pattern is present before and after, unrelated).

Pinned with a new selectChoices test. Full shared suite 1171/1171 (1
pre-existing unrelated flaky docify test excluded), full cli suite
648/648, build and lint clean on both.
foldOptionsMetadataIntoDecisionGroups's own JSDoc said a referenced id
already in a decision group gets that whole group folded into it. The
code does the opposite: every decision always gets its own new group,
and referenced ids are moved out of whatever group they were in, into
that new one. The code is right (this is the fix behind two decisions
over one catalog drawing two boxes); the comment was describing an
earlier version.
logger.warn on every discarded-key detection meant new machinery in
the merge path could interrupt normal output for a construct the
pattern's own documentation already declares unsupported (allOf for
nodes/relationships), including a false 'discarded' report on
legitimate refinement - a support question with no action attached.

Kept the heuristic; only the log level changes. Updated the five
discarded-key tests to assert mockDebug instead of mockWarn, and the
two negative assertions to check the specific discard message rather
than 'not called at all', since flattenAllOf already logs unrelated
debug traces that a bare not.toHaveBeenCalled() would collide with.

flatten-allof.spec.ts 14/14, full shared suite 1171/1171 (1
pre-existing unrelated flaky docify test excluded), build and lint
clean.
…y answer

readChoiceBlock and listCandidates named the mechanism (read a block,
list candidates), not the question a caller is actually asking - so
the wrong one was easy to reach for silently.

  readChoiceBlock -> resolveOperativeChoiceBlock: this is the function
  that resolves a block to the one form selection would act on
  (oneOf wins over anyOf), matching the file's own internal
  'operative' vocabulary (BlockResolution).

  listCandidates -> listDeclaredCandidates: pairs with the unchanged
  listSelectableCandidates - same shape, opposite resolution, so the
  two names now signal they're answering the same question two ways.

Pure rename across all 13 call sites (calm-models, shared's spectral
rules and options.ts, calm-hub-ui's patternTransformer.ts) plus
shared/AGENTS.md's own references. No behaviour change: build clean,
full calm-models/shared/calm-hub-ui/cli/vscode suites green, lint 0
errors across all five.
The Pattern Decisions section in AGENTS.md had grown to cover four
readers, the allOf disagreement, enforcement, visualiser folding, and
duplicated code - a lot for a package AI-assistant guide to carry, and
scoped to shared even though half of it describes calm-hub-ui and
calm-models behaviour too.

Moved it to shared/PATTERN-DECISIONS.md: a recap of current, intended
behaviour for how patterns express decisions, tying together rules
spread across calm-models, shared, and calm-hub-ui. Not a design
proposal - it states what the code already does and why, including
two disagreements between packages that are not fixed here:

- allOf's three readers, and why each made its own narrow choice
  instead of a real intersection merge.
- options.ts vs patternTransformer.ts on a decision holder that
  declares both oneOf and anyOf - confirmed pre-existing against main,
  and confirmed that no Spectral rule's given paths reach a decision
  holder's own nested options block, which is the reason nothing
  catches it today.

AGENTS.md's Pattern Decisions section is now a pointer plus the one
piece of testing guidance that belongs in a package guide, not a
behaviour recap. Written in Simplified Technical English per the
root AGENTS.md's documentation guidance.
@YoofiTT96

Copy link
Copy Markdown
Member Author

Code review findings

Automated review of the items-based decision support changes turned up 6 findings, most-severe first:

  1. shared/src/commands/generate/components/options.ts:53getRelationshipsPrefixItems (used by extractOptions, which feeds both interactive prompting and --option-choices) never reads relationships.items. A decision declared only via an items-catalog relationship (per this PR's own docs: "Relationship candidates can use an items catalog in the same way" as nodes) is silently never offered to the user by calm generate.
  2. calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts:808foldOptionsMetadataIntoDecisionGroups overwrites groupOptionsMap when two independent decisions resolve to the same pre-existing group (e.g. a shared items catalog collapsed to one group, referenced by two separate options-relationships). The second decision's prompt/choices silently clobber the first's.
  3. shared/src/commands/generate/components/flatten-allof.ts:1005logDroppedItemsCatalogs only fires when both allOf branches declare an items catalog on the same array property. The more common asymmetric collision (one branch has items, the other only prefixItems) still silently drops the catalog via deepMergeSchemas' shallow properties spread, with zero diagnostic — even under --debug.
  4. calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts:778 — when a single decision's choices span two pre-existing decision groups, matchedGroupIds[0] picks the surviving group (and its groupType) based on incidental array ordering in the pattern author's choices[].nodes, not any explicit rule. Lower severity — this only triggers for the "candidates split between prefixItems and items" shape the PR's own new docs call out as a pattern smell to avoid.
  5. shared/src/spectral/functions/pattern/ids-are-unique.ts:12 — the items.oneOf/items.anyOf lookup for nodes, relationships, and interfaces is hand-duplicated across ids-are-unique.ts, node-id-exists.ts, interface-id-exists-on-node.ts, and rules-pattern.ts, with no shared helper. A future change to how catalog nodes are located needs the same hand-edit in 4+ places with nothing forcing them to stay in sync.
  6. shared/src/commands/generate/components/options.ts:141 — the "oneOf wins over anyOf" catalog-alternative selection rule is reimplemented independently in options.ts (inline) and in patternTransformer.ts's own catalogAlternatives() helper, rather than sharing one implementation. shared (CLI) and calm-hub-ui (visualizer) can silently disagree on which alternatives a catalog offers if this precedence rule ever changes in one place but not the other.

Taken together, these findings share a pattern: the "candidate via items catalog" concept was added in several independent places (CLI generation, pattern linting, and the Hub UI visualizer) with the core lookup/precedence/grouping logic reimplemented by hand in each, rather than sharing one source of truth. That's what's producing both the gaps (finding 1, and the asymmetric-merge case in finding 3) and the fragility (findings 2, 4-6) above.

Given how central "options"/"decisions" are to CALM pattern generation, it might be worth taking a step back and writing a short design doc that pins down the intended current and future behaviour for options - precedence rules (oneOf vs anyOf), how things merge across allOf, how decisions fold into UI groups, and where the canonical implementation of each rule should live - before this area grows further. That would give a shared reference to design future changes against and to catch regressions like these in review, rather than each package evolving its own interpretation independently.

1, 2, 3, 5, 6 Addressed
4 Deferred to be handled in follow up issue that deals with containment

@YoofiTT96

Copy link
Copy Markdown
Member Author

@markscott-ms PR updated with changes for re-review

YoofiTT96 and others added 6 commits August 26, 2026 05:52
Strips the PR-review voice: changelog narration, comparisons against
main, and claims that follow-up work is tracked when no issue exists.

Corrects the known-disagreement section. The divergence sits inside
options.ts - extractOptionsFromBlock offers the union of both choice
keywords while flattenOneOfAndAnyOf resolves oneOf only, so an answer
from the anyOf half is accepted and then silently discarded. The
visualiser already matches generation's resolution step, and the
validation rule enumerates rather than resolves, so neither is a party
to it.

Replaces "still duplicated" with the three concrete divergences between
the two decision-holder readers. Drops the candidate-helper entry:
identical code with no behavioural disagreement is not what this
reference is for.

Corrects the enforcement note - the keyword rule does reach a decision
holder as a relationships.prefixItems entry, it just never descends into
the holder's own options block.
…eaders

listNodeInterfaces reads pattern schema and has no Spectral coupling, but
it lived in shared's Spectral rules folder, where every other file is a
rule function. It carried private isObject/readUniqueId copies only
because it was reading schema in a layer that holds no schema readers.

Export it from calm-models/pattern and delete candidate-helpers.ts. The
type is renamed DeclaredInterface - NodeInterface collided with
CalmNodeInterface, which already means a relationship endpoint.

No signature or behaviour change. Both rules still compose the document
path themselves, because the reader sees a candidate's schema without
knowing where that schema sits.
The opening line issued an authoring directive - treat allOf as
unsupported for nodes and relationships - in a maintainer-facing doc that
states it describes behaviour rather than prescribing it. It was also
broader than the rule it restated: pattern-creation.md scopes
"unsupported" to relationships, with a reason, and no in-repo pattern
uses allOf for either property.

State the fact instead, and point at the author-facing guide.

Also correct the table: the TEMPORARY marker sits on the private
resolveArrayContainer, not on getPatternArray.
…differ

The table listed three readers and their behaviour but not who calls them
or what forces each to differ, which is the question it raises.

Add the calling surfaces and the constraint behind each choice: the merge
must hand instantiate one schema, getPatternArray must return a location
and runs on the raw pattern, and the candidate readers must report a path
the document actually contains.

Add the observable consequence - on one pattern whose nodes live only in
an allOf branch, extractOptions finds the decision while
listDeclaredCandidates reports no candidates at all.
Each fix below was checked by running the code, not by reading it.

The optionId collision was described as making only the first question
block addressable. It depends on the path: interactively both questions
are asked and both answers applied, and only the logged --option-choices
replay string is lossy; loadChoicesFromInput resolves with find, so the
second block is unreachable there.

"Unreachable today" read as a property of the format. It is a property of
this repository's patterns - option-type in the meta-schema is an
unbounded array of decision objects, so a holder is designed to ask
several questions.

The allOf table listed assertChoicesAreSelectable as a caller exposed to
the split. It runs after flattenAllOf, so it never meets an allOf; the
invisibility is validation's alone.

The header said the doc never picks a winner, while "Which side changes"
picks one. Allow it where only one answer is coherent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

calm-hub-ui Affects `calm-hub-ui` cli Affects `cli` code config docs Improvements of additions to documentation shared

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support items-based (zero-or-more) candidate catalogs in pattern decisions

2 participants